diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md index a29c5bff..89581cb3 100644 --- a/CODEBASE_DOCUMENTATION.md +++ b/CODEBASE_DOCUMENTATION.md @@ -122,9 +122,29 @@ server/githubCloneWorktreeService.js - GitHub import flow for Quick Work (`owner server/portRegistry.js - Port assignment + live service scanner (`/api/ports/scan`) ├─ Windows scan path: uses hidden `netstat`/`tasklist` probes so packaged Tauri builds do not flash console windows when Ports/Dashboard panels refresh └─ UI metadata: labels orchestrator-assigned ports, known dev servers, and custom user labels +server/commanderManager.js - Holds N Commander instances keyed by id (primary 'commander' = unchanged single-Commander behavior); list/spawn/remove; per-instance PTY + cwd +server/utils/shellSafety.js - Allowlist validators for values interpolated into shell commands (model/reasoning/flags) — guards custom-agent + server-launch command construction server/commanderService.js - Top-level Commander PTY (Claude/Codex) + launch buffering ├─ Packaged CWD: uses `ORCHESTRATOR_DATA_DIR/commander` so desktop users can edit `CLAUDE.md` / `AGENTS.md` safely └─ First-run seed: copies the packaged `docs/COMMANDER_CLAUDE.md` into the Commander data directory when missing +server/evidenceService.js - Per-task evidence collection (review-readiness proof) +├─ Sources: fenced ```agent-evidence JSON blocks in PR body/comments/reviews, `.agent-evidence.json` in the worktree, direct API +├─ Merge: later sources win per scalar section; reviews/media/data accumulate de-duped; PR diff stats computed server-side +├─ Media: `GET /api/process/evidence/:id/media/:idx` streams only from the server-recorded worktree root (extension whitelist, traversal rejected) +└─ Protocol agents follow: docs/agents/EVIDENCE_PROTOCOL.md (compact snippet auto-appended to batch launch prompts via server/evidencePromptSnippet.js) +server/reviewWorkflowService.js - Data-driven multi-agent review chains (config/review-workflows.json + ~/.agent-workspace/review-workflows.json override) +├─ Stages: role + agentId + model + effort per stage; riskDefaults pick a chain per task risk +├─ Runner: spawns each reviewer into an idle worktree, polls GitHub review verdicts, records outcomes into evidence.reviews[] +└─ Run state persists on the task record (`reviewWorkflow`) — restarts resume polling; stalls on timeout, blocks on needs_fix +server/agentSpawnHelper.js - Shared worktree-locate + one-shot agent launch (used by PR review automation, review workflows) +├─ Agent-agnostic: launch flags + init delay resolve from the agentManager registry (any registered agent id works) +└─ Two-write submit: prompt text, then `\r` separately (a single "text\r" chunk is treated as a bracketed paste by agent CLIs) +server/agentManager.js - Agent registry: built-ins (claude/codex) + custom CLI agents merged from `~/.agent-workspace/custom-agents.json` +├─ Custom agents (Gemini/OpenCode/Grok/aider/...) are pure config: modes, flags, defaultFlags, per-agent `modelFlag`/`reasoningFlag` CLI syntax, initDelayMs — see config/custom-agents.example.json +└─ Registered agents surface automatically in /api/agents (agent picker UI), batch launches, and review-workflow stages +server/visibilityPresetService.js - One-click UI Mode presets (simple ↔ power/process) rewriting ui.visibility (`POST /api/user-settings/visibility-preset`) +server/contextSwitchTelemetryService.js - Local-only context-switch JSONL log + summary (Context Tax estimator; `~/.agent-workspace/telemetry/context-switches.jsonl`) +server/serverLaunchCommandResolver.js - Data-driven dev-server launch: cascaded `serverCommand` template + {{gameMode}}/{{commonFlags}} substitution (replaces hardcoded `hytopia start`) scripts/tauri/prepare-backend-resources.js - Tauri backend packager ├─ Bundles: server/client/config/templates/scripts + optional Node runtime into `src-tauri/resources/backend` ├─ Commander instructions: copies `docs/COMMANDER_CLAUDE.md` into `resources/backend/{COMMANDER_CLAUDE.md,CLAUDE.md,AGENTS.md}` for desktop builds @@ -291,7 +311,19 @@ client/styles/projects-board.css - Projects Board modal styling client/plugin-host.js - Client plugin runtime for UI slots/actions ├─ Loads: `/api/plugins/client-surface` slot actions with cache/refresh support ├─ Exposes: `window.orchestratorPluginHost` -└─ Supports actions: open_url, open_route, copy_text, commander_action +├─ Supports actions: open_url, open_route, copy_text, commander_action, post_route (local route + optional prompted input) +└─ Rendered slots: `commander.tools` (Commander panel strip), `dashboard.telemetry.actions` (telemetry overlay) + +client/queue-evidence.js - Evidence card in Queue detail (badges: tests/app-ran/reviews/media/data/diff; media lightbox; refresh) +client/queue-workflow.js - Review-workflow block in Queue detail (chain picker by risk, run/skip/cancel, stage chips) +client/visibility-preset.js - Settings → UI Mode preset switch (simple ↔ power) +client/plugins-admin.js - Settings → Plugins list (loaded + failed) + reload +client/context-telemetry.js - Fire-and-forget context-switch tracking hooks (window.ContextTelemetry) +client/styles/queue-evidence.css - Evidence card + workflow chips + cache-cold chip styling + +plugins/youtube-transcript/ - Example plugin: YouTube URL → yt-dlp subtitles → plain-text transcript in ~/Downloads/transcripts (commander.tools button + `youtube-transcript-transcribe` command) +config/review-workflows.json - Named review chains (standard/hardened/full-gate), role prompt focuses, riskDefaults +docs/agents/EVIDENCE_PROTOCOL.md - How agents report evidence (schema, channels, reviewer obligations, handoff notes) ``` ### Tabbed Workspace System (NEW) @@ -643,6 +675,16 @@ GET /api/policy/templates - Built-in team gov POST /api/policy/bundles/export - Export policy bundle (template/current/custom) for sharing POST /api/policy/bundles/import - Apply policy bundle (replace/merge) into global settings GET /api/audit/export?signed=1 - Signed audit export (HMAC-SHA256; requires signing enabled + secret) +POST /api/process/evidence/:id/refresh - Re-collect task evidence from PR comments + worktree file +PUT /api/process/evidence/:id - Directly set/merge task evidence +GET /api/process/evidence/:id/media/:idx - Stream an evidence screenshot/video (path-validated) +GET /api/process/review-workflows - Review chain catalog (workflows/roles/riskDefaults) +POST /api/process/review-workflows/:id/{start,advance,cancel} - Run/skip-stage/cancel a review chain for a PR task +POST /api/process/telemetry/context-switch - Record a local context-switch event +GET /api/process/telemetry/context-switches?hours=24 - Context-switch summary (count, est. refocus cost, top pairs) +GET /api/user-settings/visibility-presets - List UI Mode presets + current +POST /api/user-settings/visibility-preset - Apply a UI Mode preset (simple|power) + GET /api/agent-providers - List registered agent providers and capabilities GET /api/agent-providers/:providerId/sessions - List provider sessions from SessionManager POST /api/agent-providers/:providerId/resume-plan - Build provider-specific resume command/config plan diff --git a/PLANS/2026-01-20/ROLLING_LOG.md b/PLANS/2026-01-20/ROLLING_LOG.md index 1c69f788..f8b13ca3 100644 --- a/PLANS/2026-01-20/ROLLING_LOG.md +++ b/PLANS/2026-01-20/ROLLING_LOG.md @@ -681,3 +681,15 @@ Purpose: keep a terse but complete log of what changed, why, and where to resume ### Docs: remove old Q-tier terminology (merged) - Process docs now refer to tiers as `T1–T4` and interactive pressure as `T1+T2`. - PR: https://github.com/web3dev1337/claude-orchestrator/pull/219 (merged 2026-01-25) + +## 2026-07-15 — Evidence + Review Workflows + Process Layer v2 (branch feature/review-inbox-and-tier-workflows) + +Research synthesis from `optimal-agent-orcestration-system` + 6-scout code audit → PLANS/2026-07-15/EVIDENCE_REVIEW_WORKFLOWS_PLAN.md. Shipped in one PR: +- Evidence system: task-record `evidence` field, evidenceService (PR fenced blocks + worktree file + diff stats + safe media serving), Queue evidence card, docs/agents/EVIDENCE_PROTOCOL.md + auto-injected prompt snippet +- Review workflows: config/review-workflows.json chains (standard/hardened/full-gate), reviewWorkflowService stage runner (per-role agent/model/effort, GitHub verdict polling, evidence.reviews recording), Queue workflow block +- Fixed: reviewer/batch spawn config was invalid (never launched); commander slash-commands (/clear) sent as paste chunk; plugin POST routes had no body parser; server launch hardcoded `hytopia start` +- UI Mode visibility presets (simple ↔ power) + Settings section; Review Hub button un-hidden +- Prompt-cache freshness: fresh-window fixer + cache-cold chip +- Local context-switch telemetry + dashboard surfacing +- Plugins made real: youtube-transcript example, post_route action, commander.tools slot, Settings admin, README slot docs +- Multi-commander: feasibility note (PLANS/2026-07-15/MULTI_COMMANDER_FEASIBILITY.md), follow-up PR diff --git a/PLANS/2026-07-15/EVIDENCE_REVIEW_WORKFLOWS_PLAN.md b/PLANS/2026-07-15/EVIDENCE_REVIEW_WORKFLOWS_PLAN.md new file mode 100644 index 00000000..ffb9ceaf --- /dev/null +++ b/PLANS/2026-07-15/EVIDENCE_REVIEW_WORKFLOWS_PLAN.md @@ -0,0 +1,100 @@ +# Evidence + Review Workflows + Process Layer v2 (2026-07-15) + +Synthesis of the `optimal-agent-orcestration-system` research (tier system, P-A-R math, review-chain math, risk-based verification) + a 6-scout code audit of this repo. This is the implementation plan for branch `feature/review-inbox-and-tier-workflows`. + +## What already exists (don't rebuild) + +The Jan-Feb 2026 process layer is mature and mostly HIDDEN, not missing: +- `taskRecordService` (tier/risk/pFail/verify/deps/review outcomes/timers) + Queue panel (`showQueuePanel`, app.js:26380+) + Review Console + conveyors + 42 `queue-*` commands + `processStatusService` (WIP + B/W/Q/X + per-tier caps + launch gating) + `processAdvisorService` + `processTelemetryService` + `prReviewAutomationService` (single-role reviewer spawn) + prompt artifacts + dependency graph. +- Hidden via `ui.visibility` defaults (commits 8abc8aa2..858a2bcc, Feb-Mar 2026). The queue header button is additionally hidden by a HARDCODED `style="display:none"` in index.html (~line 81) despite `header.queue: true`. +- Two abandoned branches (PR #804 open/conflicting; #806 stacked into it) built `latestReview*` persistence + terminal review buttons. docs/REVIEW_SYSTEM_DOCUMENTATION.md documents that UNMERGED code. Decision: adopt field naming ideas, implement fresh (single-review model doesn't fit chains; 5 months of drift; untested). + +## Confirmed bugs found (fix in this PR) + +1. `prReviewAutomationService._spawnReviewerForPr` calls `startAgentWithConfig(sessionId, {provider, skipPermissions:true, mode:'fresh'})` but the API requires `{agentId, mode, flags:['skipPermissions']}` → validation always fails → **auto-reviewer spawn has never worked**. Correct pattern lives in `batchLaunchService.js:181` (also: prompt then `\r` as separate writes, not `prompt+'\n'`). +2. `pr-review-automation` socket event has zero client listeners (dead telemetry). +3. Queue button: visibility flag true but inline `display:none` wins. + +## Research → design constants (from FINAL_ARTICLE et al.) + +- Review chains: p_chain = Πp_i (30% → 9% → 2.7%); sweet spot 2-3 reviewers; more only for high-risk/security. Chains raise fan-out capacity ~60%. +- Risk: `impact = 0.25*live + 0.20*users + 0.20*(1-rollback_ease) + 0.20*breaks_other + 0.15*money`; `p_fail = 0.30*complexity + 0.25*testsPenalty + 0.20*novel + 0.15*(chain?0.3:1.0) + 0.10*(1-specQuality)`; `risk = 0.6*impact + 0.4*p_fail`; bands: <0.2 AUTO_MERGE / 0.2-0.4 QUICK_CHECK / 0.4-0.6 BASIC_VERIFY / >0.6 FULL_REVIEW. +- Low-testability domains (games/UI): tests are weak evidence (p_auto_catch≈30%) → screenshots/app-ran proof must be FIRST-CLASS evidence. +- Context tax: 5-15 min/switch; batch by repo/type. ρ ≤ 0.85. Caps: WIP≤5, T1≤1/T2≤2/T3≤5/T4≤1 (already in processStatusService). +- Cache: >~1h old prompt = cold cache → reprompt in FRESH window with handoff notes (ledger pattern). +- All research %s are priors, not measurements — telemetry exists to calibrate them. + +## New feature 1 — Evidence system (centerpiece) + +The 7 things a human needs at a glance per finished task: tests ran+passed · app actually ran · review-chain verdicts+fixes · screenshots/video · data/balance proof · diff stats · standards used. + +**Sources, merged by new `server/evidenceService.js`:** +1. Fenced ```agent-evidence JSON blocks in PR body + PR comments (primary; travels with the PR, cross-machine, reviewers append their own blocks as comments). +2. `.agent-evidence.json` + `.agent-evidence/` media dir in the worktree (local supplement; primary for worktree/session tasks with no PR yet). +3. Direct API: `PUT /api/process/evidence/:taskId`. +Server-computed: `diffStats` aggregated from `pullRequestService` per-file additions/deletions (never trust agent-supplied numbers for PRs). + +**Task record field `evidence`** (normalized in taskRecordService, pattern: normalizeReviewChecklist): +```json +{ "schema": 1, "updatedAt": "ISO", "summary": "...", + "tests": {"ran":true,"command":"npm test","passed":47,"failed":0,"output":"tail","at":"ISO"}, + "appRun": {"ran":true,"method":"puppeteer|server-smoke|studio|manual","url":"","notes":"","at":"ISO"}, + "media": [{"type":"image","path":".agent-evidence/feature.png","caption":""}], + "data": [{"metric":"dps","before":120,"after":90,"note":"autoplay 3 runs"}], + "reviews": [{"role":"security","agentId":"codex","model":"gpt-5.5","verdict":"approved","summary":"","findings":2,"fixed":2,"at":"ISO"}], + "standards": ["CLAUDE.md"], + "handoff": {"notes":"for successor agent"}, + "diffStats": {"files":12,"additions":340,"deletions":80} } +``` + +**UI**: evidence card in Queue `renderDetail` via new `client/queue-evidence.js` — badge row (🧪 47✅ · ▶️ ran · 🛡️✅ · 📸3 · 📊2 · 12 files +340/−80) + expandable sections + media lightbox (`GET /api/process/evidence/:taskId/media/:idx`, path-validated streaming). Evidence completeness indicator drives review-readiness. + +**Protocol**: `docs/agents/EVIDENCE_PROTOCOL.md` — how agents self-report (JSON schema + fenced-block examples + media conventions + handoff notes + how an implementer agent can run its OWN review chain). Referenced/injected by launch prompts (batchLaunch prefix, workflow templates). + +## New feature 2 — Review workflows (data-driven chains) + +`config/review-workflows.json`: named workflows (stages[] with role/agentId/model/effort/promptTemplate), role prompt templates ({{prNumber}} {{owner}} {{repo}} {{standards}} …), riskDefaults (low→standard 1-stage, high→hardened 2-stage, critical→full-gate 3-stage). New `server/reviewWorkflowService.js`: sequential stage runner on top of the fixed spawn machinery — spawn stage reviewer → detect its GitHub review + agent-evidence comment → record into `evidence.reviews[]` → next stage → done → notify. Queue detail gets workflow picker + "Run review workflow" + per-stage status chips. Per-role model: claude `--model ` (extend buildClaudeCommand), codex `-m -c model_reasoning_effort=`. Research note: reviewer model strength should scale with risk (cheap reviewer for low-risk, strong for security/high-risk) — encode in the default config, keep data-driven. + +## New feature 3 — Review Hub surfacing + visibility presets + +- Remove hardcoded display:none from queue button; label "Review". +- `ui.visibilityPreset`: `simple` (today's defaults) | `power` (workflow modes, tier filters, PRs, review route, activity, diff, process banner, dashboard process cards, commander controls ON). Server: preset maps + `POST /api/user-settings/visibility-preset`. Settings panel: preset switch section (finally a UI for this — none exists today). + +## New feature 4 — Cache freshness + fresh-window reprompt + +promptAge from `promptSentAt`; >55 min → queue detail + reprompt actions warn "cache cold — use fresh window"; "Reprompt (fresh)" action spawns fresh-mode session seeded with `evidence.handoff.notes` + prompt artifact. + +## New feature 5 — Context-switch telemetry (local-only) + +`server/contextSwitchTelemetryService.js` → JSONL `~/.orchestrator/telemetry/context-switches.jsonl`; `POST /api/process/telemetry/context-switch` + summary endpoint (switches/day, est. cost via 10-min default, top thrash pairs); client emit on focus-worktree / workspace switch / workflow-mode change / review start-end; surfaced in dashboard Process section + advisor rule. Whole-computer monitoring = future note only. + +## New feature 6 — Commander fixes + +`/clear` slash-command passthrough fix + second-commander feasibility (pending scout-commander report; implement smallest sound fix). + +## New feature 7 — Plugins made real + +Example plugin `plugins/youtube-transcript/` (yt-dlp subtitle fetch route + registered command; graceful "install yt-dlp" recommendation when missing); `commander.tools` slot renderer in commander panel free real estate; plugin admin list + reload in Settings; document `client.slots` in plugins/README.md (currently undocumented). + +## New feature 8 — Play buttons revival (stretch) + +Uncomment/modernize Start Server block (app.js:4537-4555) behind existing `terminal.serverLaunchMenu`/`startServerDev` flags; wire `getDynamicLaunchOptions()` (cascaded gameModes/commonFlags); implement `{{gameMode}}`/`{{commonFlags}}` substitution the docs promise but code never had. + +## Commit plan (each pushed; priority order if interrupted) + +1. docs: this plan + ai-memory update +2. fix: reviewer spawn config bug + two-step prompt (+tests) +3. feat: evidence field in task records (+tests) +4. feat: evidenceService + APIs + diffStats (+tests) +5. feat: queue evidence card UI +6. feat: review workflows config + service + queue actions (+tests) +7. docs: EVIDENCE_PROTOCOL.md + prompt injection +8. feat: visibility presets + review hub surfacing +9. feat: cache-freshness + fresh reprompt +10. feat: context-switch telemetry +11. fix/feat: commander (/clear, layout, maybe multi) +12. feat: plugins (example + slot renderer + admin + docs) +13. feat: play buttons (stretch) +14. docs: CODEBASE_DOCUMENTATION.md + PR + +Out of scope (documented, future): whole-computer context monitor; Bayesian per-bucket p tracking (telemetry fields land now, math later); triage 3-bucket Trello pipeline (spec exists, big); multi-commander full implementation if seam is large; heavy queue renames (Feb-21 report's full consolidation). diff --git a/PLANS/2026-07-15/MULTI_COMMANDER_FEASIBILITY.md b/PLANS/2026-07-15/MULTI_COMMANDER_FEASIBILITY.md new file mode 100644 index 00000000..88a73750 --- /dev/null +++ b/PLANS/2026-07-15/MULTI_COMMANDER_FEASIBILITY.md @@ -0,0 +1,44 @@ +# Multi-Commander Feasibility (2026-07-15) + +> **UPDATE 2026-07-16 — SHIPPED.** Multiple commander instances now exist: +> `CommanderManager` keys N instances by id (primary stays `commander`, fully +> backward compatible), id-aware PTY-lifecycle routes, scoped socket payloads, +> `list`/`spawn`/`remove` routes, and a titlebar tab-switcher that rebinds the +> single panel to another backend PTY. Remaining follow-up is only full +> side-by-side N-panel rendering (see end). Original analysis below. + +Ask: "sometimes I just need another commander." + +## Current seams (why it's not a small change) + +`server/commanderService.js` is a hard singleton around ONE PTY: +- `this.session` (single PTY object, hardcoded `id: 'commander'`), `this.isReady`, `this.claudeStarted`, `this.claudeLaunchState` (launch buffering/queue), `this.outputBuffer` (single history) — all singular state. +- `COMMANDER_CWD` is computed **once at module load** from env (`commanderService.js:47-55`) — must move into per-instance constructor options. Data dir: one `ORCHESTRATOR_DATA_DIR/commander` cwd with one CLAUDE.md. +- ~25 REST routes (`server/index.js:7772-8085`: status/start/start-claude/input/resize/stop/restart/output/clear/sessions/send-to-session/execute/execute-text/context/prompt/capabilities…) take zero commander-id parameter — all close over the one boot-time instance. +- Socket broadcasts are global and unscoped: `io.emit('commander-output', {data})` / `commander-exit` carry no instance id — N commanders need `{commanderId}` payloads plus rooms or client-side filtering. +- `CommanderContextService.getInstance()` is also a singleton snapshotting one global UI state. +- `client/commander-panel.js` is instantiated exactly once (`app.js:1187`) with fixed DOM ids (`#commander-panel`, `#commander-terminal`, `#commander-*`), one xterm, one `inputChain`; the cmd-mode localStorage key is global. +- Useful precedent: the Commander panel is deliberately EXCLUDED from the workspace tab-manager's per-tab state swapping — but that swap machinery (terminals/sessions swapped per tab) is a ready-made template for "one commander per workspace tab" instead of a green-field design. + +## Recommended path (follow-up PR) + +1. Extract per-instance state into `CommanderInstance` (pty/ready/launch-state/buffer/cwd); `CommanderService` becomes a `Map` with `'main'` as the default. +2. Routes gain an optional `:id` (default `main`) — fully backwards compatible: `/api/commander/:id?/input`. Socket events carry `{ commanderId }`. +3. Client: parameterize DOM ids (`commander-panel-`), render a small instance switcher (+ button) in the titlebar; each instance gets its own xterm + input chain. Second instance cwd: `commander//` so each can have its own CLAUDE.md persona. +4. Keep Commander CLAUDE.md shared by default with optional per-instance override. + +Estimated diff: ~400-600 lines across commanderService/index routes/commander-panel + tests. No data-model changes. + +## Related follow-up: Commander status strip + +The biggest unused real estate inside the Commander window is a persistent status strip between the toolbar and the terminal (live session count / queue depth / blocked items / recent advice preview) — `commanderContextService.getSnapshot()` already assembles that data server-side. The new `commander.tools` plugin strip occupies part of that region; a status strip would sit beside/above it. + +## `/clear` bug post-mortem (two layers) + +1. Pre-PR-#1001 (fixed upstream 2026-07-12): captured slash commands unrecognized by the orchestrator parser were discarded entirely — `/clear` never reached the agent at all. +2. Post-#1001 residual (fixed in this branch): the forward path sent `"/clear\r"` as ONE pty write — agent CLIs treat a multi-char chunk with a trailing `\r` as a bracketed paste (inserted as text, not submitted). Now text and `\r` are separate writes (300ms apart), matching the two-write submit rule used everywhere else. Remaining known gap: commands typed before the Commander agent is ready surface an explicit `[cmd] ✗ … not delivered` line (PR #1008) — easy to scroll past, could become a toast. + +## Interim workarounds (available today) + +- Any worktree agent terminal can act as a second orchestrating agent — paste the Commander docs path into its prompt (`docs/COMMANDER_CLAUDE.md`) and it has the same API powers (the API is open locally). +- The command palette (Ctrl/Cmd+K) + the new `commander.tools` plugin slot cover many "just run a thing" cases without occupying the Commander. diff --git a/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/decisions.md b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/decisions.md new file mode 100644 index 00000000..8f1a6fb5 --- /dev/null +++ b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/decisions.md @@ -0,0 +1,5 @@ +# Decisions & Dead Ends + +- 2026-07-15: Extend the EXISTING Queue/process layer rather than build a parallel review system. The Jan-Feb work (task records, queue, conveyors, automations) is the foundation; the new value is (a) evidence manifests, (b) consolidation/visibility preset, (c) data-driven review chains, (d) context-freshness, (e) context-switch telemetry. +- 2026-07-15: "Simple vs Process mode" preset instead of un-hiding everything by default — the open-source simplification was deliberate; a one-click preset honors both audiences. +- 2026-07-15: ai-memory folder named with branch-point sha (affdc657) since first commit sha can't exist before the commit. diff --git a/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/init.md b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/init.md new file mode 100644 index 00000000..37f95c93 --- /dev/null +++ b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/init.md @@ -0,0 +1,24 @@ +# Init — User Request (2026-07-15) + +Voice-transcribed brain dump. Branch: `feature/review-inbox-and-tier-workflows` from origin/main @ affdc657. + +## Core request +Make agent-workspace (fka claude-orchestrator) the best agent orchestrator. Many features were built pre-open-source, then hidden/toggled off (`ui.visibility`) and some were "spammed out by Codex" untested. Revive, harden, extend — guided by the research repo `web3dev1337/optimal-agent-orcestration-system` (tier 1-4 system, P-A-R cycle, context-switching math, ~38k-word article). + +## Specific asks (verbatim-ish) +1. **Tier workflow**: T1 = active focus (don't wait on PAR cycle), T2 = standby gap-fillers ready when T1 blocks, T3 = batch-prompted background agents (prompt all at once → work for hours → batch review), T4 lowest. Batch context switching. +2. **Review dashboard**: after a T1 block, review all finished T3s in a dashboard that shows AT A GLANCE what a human needs to approve/merge: + - (1) automated tests confirmation, (2) proof the app/game actually RAN (Roblox Studio / browser / MonoGame etc.), (3) agent-review chain results + whether fixes were applied per review, (4) screenshots/videos of the feature, (5) data evidence for balance-type changes (before/after, autoplay confirmation), (6) diff stats (files, LOC) + diff viewer link, (7) what standards were reviewed against (code-quality standards injected into implementer AND reviewers). + - Actions: approve / reprompt / merge → auto-advance to next item. Re-reviewed items reappear at end; otherwise "pending". +3. **Agent chains/workflows**: implementer → 1-3 reviewer agents (security/performance/general roles) passing info back and forth, hardening before human review. Prompt libraries + workflows; per-role model/effort choice (e.g. Claude fable can't do security reviews, Codex/Opus can). Also: agent itself can orchestrate its own reviews via baked-in instructions/skill rather than hardcoding. +4. **Context management**: >1h wait = prompt cache likely expired → reprompt should happen in a FRESH window; agents leave notes for successor agents. +5. **Telemetry**: track context switching etc. Local-only unless opted in (optional cloud saves). Side-idea: opt-in whole-computer context-switch monitor (not just orchestrator). +6. **Play buttons**: parameterized run configs (game modes, cheat flags, server params) — used to be hardcoded to Hytopia, removed; cascaded config (gameModes/commonFlags) exists server-side. +7. **Plugins/tools**: e.g. paste YouTube link → transcribe workflow routed to right repo; modular buttons/workflows anyone can define. Plugin system exists (Codex batch, untested). +8. **Commander**: sometimes need a SECOND commander; `/clear` slash command doesn't work in commander terminal — fix. +9. **Layout**: spare screen real estate in commander/projects/tasks/ports area for new stuff. + +## Constraints +- Use sonnet sub-agents for research/scouting, opus/fable for synthesis. Sub-agents must NOT create branches. +- Commit + push as you go (rug-pull protection). One PR. +- Running production instance lives in ~/GitHub/tools/automation/claude-orchestrator/claude-orchestrator-dev (ports 4000/2081) — this worktree (work1) is safe to edit. diff --git a/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/plan.md b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/plan.md new file mode 100644 index 00000000..aaa41b5b --- /dev/null +++ b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/plan.md @@ -0,0 +1,30 @@ +# Plan (living doc — updated as scouts report) + +## Key discovery (before scouts) +Most of the tier/queue/review vision ALREADY EXISTS (built Jan-Feb 2026, PRs #179-#275+): +- taskRecordService (tier/changeRisk/pFailFirstPass/verifyMinutes/promptRef/deps/review outcomes/telemetry timestamps) → `~/.orchestrator/task-records.json` +- processTaskService (Queue = PRs + ready worktrees + waiting sessions), processStatusService (WIP + B/W/Q/X banner), processAdvisorService, processPairingService, processTelemetryService, prReviewAutomationService (auto reviewer/fixer/recheck spawn), worktreeTagService, pullRequestService, commandRegistry + voiceCommandService (queue-* commands) +- Queue panel + Review Console + PRs panel + Review Route + conveyor T2/T3 + workflow modes (Focus/Review/Background/All) in client/app.js +- Prompt artifacts (private/shared/encrypted) + promotion +- MOSTLY HIDDEN via ui.visibility defaults in user-settings.default.json (open-source simplification) +- PLANS/2026-02-21/REVIEW_QUEUE_WORKFLOW_REPORT.md recommends consolidation into single "Review Hub" — never implemented (verify) + +## Implementation phases (one PR, commit per phase) +1. **Docs**: this ai-memory + PLANS/2026-07-15/ design doc (write after scout synthesis). +2. **Evidence system (NEW — the centerpiece)**: + - Evidence manifest convention agents write in worktree (JSON + media folder) + - server/evidenceService.js: discover/parse/validate manifests; merge into queue task detail + - Schema: tests{}, appRun{}, reviews[](role/model/verdict/fixes), media[], data[], diffStats, handoffNotes, standards + - docs/agents/EVIDENCE_PROTOCOL.md + prompt-injectable template so agents self-report (+ self-orchestrated review chains) + - Queue detail Evidence card UI (at-a-glance checklist: tests ✅ ran ✅ reviews 2/2 ✅ media 📸 data 📊) +3. **Review Hub consolidation** (Feb 21 report recs): single Review Hub entry; workflow-visibility PRESET toggle ("Simple" vs "Process/Power" mode) that flips the hidden flags in one click instead of 30 individual toggles. +4. **Workflow chains / prompt library**: config/review-workflows.json — data-driven role chains (security/perf/general reviewer, fixer) with per-role model/effort/prompt template, per-risk chain length; wire into existing spawn actions; record per-stage results. +5. **Context freshness**: promptSentAt age tracking → "cache cold (>55m): reprompt in fresh window" warning + handoff-notes flow. +6. **Context-switch telemetry (local)**: log workspace/worktree/panel/review focus switches to ~/.orchestrator JSONL + summary endpoint + advisor hook. +7. **Commander**: fix `/clear` not working; multi-commander if seam is small (else document). +8. **Plugins**: verify pipeline works; ship example plugin (YouTube transcript button) if wiring sound. +9. Tests (unit for new services + e2e safe where cheap), CODEBASE_DOCUMENTATION.md update, PR. + +## Verify before merge +- node --check on touched server files; npm run test:unit; targeted e2e safe. +- Never touch ~/GitHub/tools/automation/claude-orchestrator/* (running prod). diff --git a/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/progress.md b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/progress.md new file mode 100644 index 00000000..17e5253a --- /dev/null +++ b/ai-memory/feature-review-inbox-and-tier-workflows-affdc657/progress.md @@ -0,0 +1,25 @@ +# Progress + +- [x] Branch created from origin/main @ affdc657; PR not yet opened (open at end) +- [x] Research: 6 sonnet scouts; digests saved in scratchpad /digests/*.md (framework, specs, impl, plugins, tiercode; commander scout never reported — pinged, then did commander work myself) +- [x] Design doc PLANS/2026-07-15/EVIDENCE_REVIEW_WORKFLOWS_PLAN.md (commit c85fcf92) +- [x] Phase 2: fix reviewer/batch spawn bugs (agentId/flags/two-write submit/repoName session id) + tests (530f7383) +- [x] Phase 3: task record `evidence` field + normalizer + tests (2fa27a34) +- [x] Phase 4: evidenceService (PR body/comments fenced blocks + worktree file + diffStats + safe media endpoint) + routes + tests (b504b7b7) +- [x] Phase 5: Queue evidence card UI (client/queue-evidence.js + css + renderDetail wiring) (85e2bb7f) +- [x] Phase 6: review workflows — config/review-workflows.json, reviewWorkflowService (chain runner, GitHub-verdict polling, evidence.reviews recording, stall/blocked states), agentSpawnHelper extraction, claude --model support, queue workflow block UI + routes + tests (73c9c2f2) +- [x] Phase 7: docs/agents/EVIDENCE_PROTOCOL.md + evidencePromptSnippet auto-injected into batch launch prompts + tests (8228021b) +- [x] Phase 8: visibility presets Simple/Power + Settings "UI Mode" section + un-hid queue button ("📥 Review" hub) + tests (33a066ee) +- [x] Phase 9: prompt-cache freshness — fresh-window fixer (implements stubbed autoSpawnFixer), 🧊 cache-cold chip + tests (1665e198) +- [x] Phase 10: context-switch telemetry service + client hooks (workflow mode/worktree focus/workspace switch/review timers) + dashboard telemetry overlay line + tests (14c0f819) +- [x] Phase 11a: commander /clear fix — captured slash cmds were sent as one "/text\r" paste chunk; now two writes (1aafc943) +- [x] Phase 12: plugins (d5f9e5f8) — example youtube-transcript plugin, post_route action type, commander.tools slot renderer, plugin admin in Settings, README client.slots docs +- [x] Phase 13: play buttons — serverLaunchCommandResolver, data-driven serverCommand + {{gameMode}}/{{commonFlags}}, UI re-enabled behind flags (31508193) +- [x] Phase 11b: multi-commander feasibility note — PLANS/2026-07-15/MULTI_COMMANDER_FEASIBILITY.md (follow-up PR, ~400-600 lines) +- [x] Phase 14: docs updated (35de0dfe), 652 unit tests green, command-surface drift clean, PR created + +## Key facts for resume +- Worktree node_modules installed (npm ci done). npm run test:unit green (620+ tests). Never touch ~/GitHub/tools/automation/claude-orchestrator/* (running prod, port 4000). +- Smoke-test servers: use random port 55xx, NOT 3000/4000; task records live at ~/.agent-workspace/task-records.json (I once wrote+removed task:smoke). +- Evidence sources merge order: existing record → PR blocks → worktree file; worktreePath is server-set only (agent blocks stripped); media served only from within that root. +- Commit style: type: subject + body + Co-Authored-By: Claude Fable 5 . Push after every commit. diff --git a/client/app.js b/client/app.js index b80f76fc..851070d7 100644 --- a/client/app.js +++ b/client/app.js @@ -1471,6 +1471,16 @@ class ClaudeOrchestrator { this.notificationManager.handleNotification(notification); }); + // Live-update the open Queue detail when a review workflow progresses. + // The server emits stage transitions on this channel; without a listener + // the workflow card's chips only refresh after the user's own clicks. + this.socket.on('review-workflow', (payload) => { + const live = window.__queueDetailLive; + if (live && payload?.taskId && payload.taskId === live.taskId) { + try { live.reload(); } catch { /* detail may have closed */ } + } + }); + this.socket.on('session-exited', ({ sessionId, exitCode }) => { this.handleSessionExit(sessionId, exitCode); }); @@ -3192,6 +3202,7 @@ class ClaudeOrchestrator { // Second-layer filter only: do NOT modify worktree visibility (visibleTerminals) or tierFilter. this.updateTerminalGrid(); this.buildSidebar(); + window.ContextTelemetry?.track('workflow-mode', normalized); // Persist for the user. this.updateGlobalUserSetting('ui.workflow.mode', normalized); @@ -4533,26 +4544,27 @@ class ClaudeOrchestrator { if (isRunning) { html += ``; + } else if (showStartServer) { + // Play buttons: options come from the cascaded config's gameModes/ + // commonFlags (Global → Category → Framework → Project → Worktree); + // the server resolves the actual command from `serverCommand`. + if (showServerLaunchMenu) { + html += `
+ + ${showLaunchSettings ? `` : ''} +
`; + } else { + html += ``; + if (showLaunchSettings) { + html += ``; + } + } } - // START SERVER BUTTON — disabled, kept for future re-enablement - // } else if (showStartServer) { - // if (showServerLaunchMenu) { - // html += `
- // - // ${showLaunchSettings ? `` : ''} - //
`; - // } else { - // html += ``; - // if (showLaunchSettings) { - // html += ``; - // } - // } - // } // Add dynamic buttons from config const buttons = this.getButtonsForSession(sessionId, 'server'); @@ -4599,14 +4611,13 @@ class ClaudeOrchestrator { const visibility = this.getTerminalVisibilityConfig(); const isRunning = this.serverStatuses.get(serverSessionId) === 'running'; - // Only show stop button; start is gated behind explicit opt-in + // Only show stop button by default; start is gated behind explicit opt-in if (isRunning) { return ``; } - // START SERVER (dev) — disabled, kept for future re-enablement - // if (visibility.startServerDev === true) { - // return ``; - // } + if (visibility.startServerDev === true) { + return ``; + } return ''; } @@ -5473,6 +5484,7 @@ class ClaudeOrchestrator { showOnlyWorktree(worktreeIdOrKey) { console.log(`Showing only worktree: ${worktreeIdOrKey}`); + window.ContextTelemetry?.track('worktree-focus', String(worktreeIdOrKey || '')); // Clear all visible terminals first this.visibleTerminals.clear(); @@ -19071,6 +19083,7 @@ class ClaudeOrchestrator { switchToWorkspace(workspaceId) { console.log('Switching to workspace:', workspaceId); + window.ContextTelemetry?.track('workspace-switch', String(workspaceId || '')); this.socket.emit('switch-workspace', { workspaceId }); } @@ -27695,6 +27708,7 @@ class ClaudeOrchestrator { const endIso = endedAtIso || new Date().toISOString(); state.reviewTimer.taskId = null; state.reviewTimer.startedAtMs = null; + window.ContextTelemetry?.track('review-end', activeId); try { const rec = await upsertRecord(activeId, { reviewEndedAt: endIso }); updateTaskRecordInState(activeId, rec); @@ -27747,6 +27761,7 @@ class ClaudeOrchestrator { const nowMs = Date.now(); state.reviewTimer.taskId = id; state.reviewTimer.startedAtMs = nowMs; + window.ContextTelemetry?.track('review-start', id); try { const rec = await upsertRecord(id, { reviewStartedAt: new Date(nowMs).toISOString(), @@ -28698,6 +28713,15 @@ class ClaudeOrchestrator { const nextAutoSnoozeMs = computeBackoffMs(snoozeCount + 1); const nextAutoSnoozeLabel = formatBackoff(nextAutoSnoozeMs); + // Prompt caches expire after ~1h idle: reprompting the original session + // past that point restarts from a cold cache — prefer a fresh window + // seeded with the evidence handoff notes. + const promptAgeMs = promptSentAt ? Math.max(0, nowMs - parseIso(promptSentAt)) : 0; + const cacheCold = promptAgeMs > 55 * 60 * 1000; + const cacheColdChip = cacheCold + ? `🧊 cache cold — fresh window on reprompt` + : ''; + const identitySaved = Array.isArray(this.userSettings?.global?.ui?.identity?.saved) ? this.userSettings.global.ui.identity.saved : []; @@ -28714,7 +28738,7 @@ class ClaudeOrchestrator {
${escapeHtml(t.title || t.id)}
-
${escapeHtml(t.id)}
+
${escapeHtml(t.id)} ${cacheColdChip}
${hasPR ? `↗ GitHub` : ''} @@ -28745,6 +28769,10 @@ class ClaudeOrchestrator {
` : ''} + ${window.QueueEvidence ? window.QueueEvidence.renderCard(t, record) : ''} + + ${window.QueueWorkflow ? window.QueueWorkflow.renderCard(t, record) : ''} +
Tier + Risk
@@ -28989,6 +29017,39 @@ class ClaudeOrchestrator {
`; + const reloadDetailRecord = async () => { + if (!detailEl.isConnected) return; // detail panel was closed/replaced + try { + const recRes = await fetch(`/api/process/task-records/${encodeURIComponent(t.id)}`); + if (recRes.ok) { + const data = await recRes.json(); + if (data?.record) t.record = data.record; + } + } catch { /* keep the stale record if the re-fetch fails */ } + renderDetail(t); + }; + + // Register for server-pushed review-workflow events (socket listener in + // setupSocketListeners) so stage chips update without user interaction. + window.__queueDetailLive = { taskId: t.id, reload: reloadDetailRecord }; + + window.QueueWorkflow?.wire(detailEl, t, record, { + onChanged: reloadDetailRecord + }); + + window.QueueEvidence?.wire(detailEl, t, record, { + onRefresh: async () => { + const worktreePath = t.worktreePath || record?.evidence?.worktreePath || ''; + const res = await fetch(`/api/process/evidence/${encodeURIComponent(t.id)}/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(worktreePath ? { worktreePath } : {}) + }); + if (!res.ok) throw new Error(`Evidence refresh failed (${res.status})`); + await reloadDetailRecord(); + } + }); + const tierEl = detailEl.querySelector('#queue-tier'); const riskEl = detailEl.querySelector('#queue-change-risk'); const pfEl = detailEl.querySelector('#queue-pfail'); diff --git a/client/commander-panel.js b/client/commander-panel.js index cbfa1660..a08fe40c 100644 --- a/client/commander-panel.js +++ b/client/commander-panel.js @@ -24,6 +24,30 @@ class CommanderPanel { this.lastSyncedSize = null; this.resizeObserver = null; this.inputChain = Promise.resolve(); + // Multi-commander: which instance this single panel is currently bound to. + // 'commander' is the primary; switching rebinds the same xterm to another + // backend PTY (tab-style), so the DOM/terminal is reused, not duplicated. + this.activeCommanderId = 'commander'; + this.knownCommanders = [{ id: 'commander', primary: true }]; + } + + // Merge the active commander id into a request body (JSON string). + cmdBody(extra = {}) { + return JSON.stringify({ ...extra, commanderId: this.activeCommanderId }); + } + + // Append the active commander id to a GET path's query string. + cmdUrl(pathAndQuery) { + const sep = pathAndQuery.includes('?') ? '&' : '?'; + return `${this.serverUrl}${pathAndQuery}${sep}commanderId=${encodeURIComponent(this.activeCommanderId)}`; + } + + // An incoming socket payload belongs to this panel if it targets the active + // commander. Undefined id = the primary (backward compatible with older + // server builds that emitted no commanderId). + matchesActiveCommander(commanderId) { + const id = commanderId || 'commander'; + return id === this.activeCommanderId; } fitTerminalSoon() { @@ -54,7 +78,7 @@ class CommanderPanel { fetch(`${this.serverUrl}/api/commander/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows }) + body: this.cmdBody({ cols, rows }) }).then(async (response) => { const data = await response.json().catch(() => ({})); if (!response.ok || data.success !== true) { @@ -77,6 +101,116 @@ class CommanderPanel { this.setupSocketListeners(); await this.fetchStatus(); this.updateCommanderTitle(); + this.refreshCommanderTabs(); + } + + // ---- Multi-commander tabs ------------------------------------------------- + + async refreshCommanderTabs() { + try { + const res = await fetch(`${this.serverUrl}/api/commander/list`); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data?.commanders) && data.commanders.length) { + this.knownCommanders = data.commanders; + } + } + } catch { + // keep last-known list + } + this.renderCommanderTabs(); + } + + renderCommanderTabs() { + const bar = document.getElementById('commander-tabs'); + if (!bar) return; + const tabs = this.knownCommanders.map((c) => { + const active = c.id === this.activeCommanderId ? ' active' : ''; + const dot = c.running ? '🟢' : '⚪'; + const label = c.primary ? 'Commander' : c.id; + const close = c.primary ? '' : ``; + return ``; + }).join(''); + bar.innerHTML = tabs + ``; + + bar.querySelectorAll('[data-commander-tab]').forEach((el) => { + el.addEventListener('click', (e) => { + if (e.target?.dataset?.commanderClose) return; // handled below + this.switchCommander(el.dataset.commanderTab); + }); + }); + bar.querySelectorAll('[data-commander-close]').forEach((el) => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeCommander(el.dataset.commanderClose); + }); + }); + const addBtn = document.getElementById('commander-tab-add'); + if (addBtn) addBtn.addEventListener('click', () => this.spawnCommander()); + } + + async spawnCommander() { + const suggested = `c${this.knownCommanders.length}`; + const id = (window.prompt('New Commander id (lowercase letters/digits/dashes):', suggested) || '').trim(); + if (!id) return; + try { + const res = await fetch(`${this.serverUrl}/api/commander/spawn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + window.alert(data?.error || 'Failed to add commander'); + return; + } + if (Array.isArray(data?.commanders)) this.knownCommanders = data.commanders; + await this.switchCommander(data.id || id); + } catch (e) { + window.alert(`Failed to add commander: ${e.message}`); + } + } + + async removeCommander(id) { + if (id === 'commander') return; + try { + const res = await fetch(`${this.serverUrl}/api/commander/remove`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }); + const data = await res.json().catch(() => ({})); + if (Array.isArray(data?.commanders)) this.knownCommanders = data.commanders; + } catch { + // best-effort + } + if (this.activeCommanderId === id) { + await this.switchCommander('commander'); + } else { + this.renderCommanderTabs(); + } + } + + // Rebind the single panel/terminal to a different backend commander PTY. + async switchCommander(id) { + const target = String(id || 'commander').trim(); + if (target === this.activeCommanderId) return; + this.activeCommanderId = target; + this.lastSyncedSize = null; + + // Reset the terminal view; the target's buffer is replayed by + // fetchInitialOutput() once the (re)bound status is known. + if (this.terminal) { + this.terminal.clear(); + this.terminal.reset(); + } + this.renderCommanderTabs(); + + // Ensure the target is started, then replay its output into the terminal. + await this.startCommander(); + await this.fetchInitialOutput(); + await this.refreshCommanderTabs(); + if (this.terminal) this.terminal.focus(); } /** @@ -128,6 +262,7 @@ class CommanderPanel {
+
@@ -147,6 +282,7 @@ class CommanderPanel { Advice
+

Commander is a Claude Code terminal for orchestrating your sessions.

@@ -173,6 +309,55 @@ class CommanderPanel { document.body.appendChild(advice); this.orchestrator?.applyUiVisibility?.(); + this.renderToolsStrip(); + } + + // Plugin tools (client.slots targeting "commander.tools") render as a + // button strip between the toolbar and the terminal. + async renderToolsStrip() { + const strip = document.getElementById('commander-tools-strip'); + const host = window.orchestratorPluginHost; + if (!strip || !host) return; + + try { + await host.refresh({ slot: 'commander.tools' }); + } catch { + return; // no plugin surface available — leave the strip hidden + } + + const items = host.getSlotItems('commander.tools'); + if (!items.length) { + strip.classList.add('hidden'); + strip.innerHTML = ''; + return; + } + + strip.classList.remove('hidden'); + strip.innerHTML = ''; + for (const item of items) { + const btn = document.createElement('button'); + btn.className = 'commander-btn commander-tool-btn'; + btn.textContent = item.label || item.id; + if (item.description) btn.title = item.description; + btn.addEventListener('click', async () => { + btn.disabled = true; + const original = btn.textContent; + try { + const result = await host.runAction(item, { orchestrator: this.orchestrator }); + if (result?.cancelled) return; + const message = result?.data?.message || (result?.ok ? 'Done' : (result?.error || 'Failed')); + if (this.terminal) { + this.terminal.writeln(`\r\n[tool] ${item.label || item.id}: ${result?.ok ? '✓' : '✗'} ${message}\r`); + } else { + btn.textContent = result?.ok ? '✓ done' : '✗ failed'; + setTimeout(() => { btn.textContent = original; }, 2500); + } + } finally { + btn.disabled = false; + } + }); + strip.appendChild(btn); + } } setPlaceholderMessages(lines = []) { @@ -584,7 +769,8 @@ class CommanderPanel { socket.off('commander-output'); socket.off('commander-exit'); - socket.on('commander-output', ({ data }) => { + socket.on('commander-output', ({ data, commanderId }) => { + if (!this.matchesActiveCommander(commanderId)) return; if (this.terminal && !this.historyPending) { this.terminal.write(data); } else { @@ -593,7 +779,8 @@ class CommanderPanel { } }); - socket.on('commander-exit', ({ exitCode }) => { + socket.on('commander-exit', ({ exitCode, commanderId }) => { + if (!this.matchesActiveCommander(commanderId)) return; this.isRunning = false; // A restarted PTY comes back at its default size, so force a re-sync this.lastSyncedSize = null; @@ -609,7 +796,7 @@ class CommanderPanel { */ async fetchStatus() { try { - const response = await fetch(`${this.serverUrl}/api/commander/status`); + const response = await fetch(this.cmdUrl('/api/commander/status')); if (response.ok) { const status = await response.json(); this.isRunning = status.running; @@ -734,7 +921,9 @@ class CommanderPanel { this.startCommanderPromise = (async () => { try { const response = await fetch(`${this.serverUrl}/api/commander/start`, { - method: 'POST' + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: this.cmdBody() }); const result = response.ok ? await response.json() @@ -776,7 +965,9 @@ class CommanderPanel { async stopCommander() { try { const response = await fetch(`${this.serverUrl}/api/commander/stop`, { - method: 'POST' + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: this.cmdBody() }); if (response.ok) { @@ -802,7 +993,7 @@ class CommanderPanel { const response = await fetch(`${this.serverUrl}/api/commander/start-claude`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode }) + body: this.cmdBody({ mode }) }); if (response.ok) { @@ -921,15 +1112,24 @@ class CommanderPanel { // sendInput swallows network errors (resolves undefined) and the server // replies { success: false } when the Commander PTY isn't running yet. // Either way the command went nowhere — say so instead of eating it. - const response = await this.sendInput(`${text}\r`); + // + // The text and the submitting "\r" MUST be separate writes with a gap: + // a single "/clear\r" chunk reaches the agent CLI as a bracketed paste, + // which inserts it as literal text and never executes the slash command. + const response = await this.sendInput(text); let delivered = false; if (response && response.ok) { const data = await response.json().catch(() => null); delivered = data?.success !== false; } - if (!delivered && this.terminal) { - this.terminal.writeln(`\r\n[cmd] ✗ Commander agent is not running — ${text} was not delivered\r`); + if (!delivered) { + if (this.terminal) { + this.terminal.writeln(`\r\n[cmd] ✗ Commander agent is not running — ${text} was not delivered\r`); + } + return; } + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.sendInput('\r'); } handleTerminalData(data) { @@ -988,7 +1188,7 @@ class CommanderPanel { .then(() => fetch(`${this.serverUrl}/api/commander/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input }) + body: this.cmdBody({ input }) })) .catch((error) => { console.error('Failed to send input:', error); @@ -1002,7 +1202,7 @@ class CommanderPanel { */ async fetchInitialOutput() { try { - const response = await fetch(`${this.serverUrl}/api/commander/output?lines=500`); + const response = await fetch(this.cmdUrl('/api/commander/output?lines=500')); if (response.ok) { const { output } = await response.json(); if (output && this.terminal) { @@ -1028,7 +1228,7 @@ class CommanderPanel { */ async checkStatus() { try { - const response = await fetch(`${this.serverUrl}/api/commander/status`); + const response = await fetch(this.cmdUrl('/api/commander/status')); if (response.ok) { const status = await response.json(); this.isRunning = status.running; diff --git a/client/context-telemetry.js b/client/context-telemetry.js new file mode 100644 index 00000000..ed41c774 --- /dev/null +++ b/client/context-telemetry.js @@ -0,0 +1,26 @@ +// Local context-switch telemetry (fire-and-forget; nothing leaves the +// machine). app.js calls track() on worktree focus, workspace switch, +// workflow-mode change and review start/end. The Context Tax law: every +// switch costs ~5-15 min of refocus — measuring it is the first step to +// batching it away. +(function () { + 'use strict'; + + const state = { lastByType: {} }; + + const track = (type, to, meta) => { + try { + const from = state.lastByType[type] ?? null; + if (from === to) return; + state.lastByType[type] = to; + fetch('/api/process/telemetry/context-switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, from, to, meta }), + keepalive: true + }).catch(() => {}); + } catch { /* telemetry must never break the UI */ } + }; + + window.ContextTelemetry = { track }; +})(); diff --git a/client/dashboard.js b/client/dashboard.js index fdb6383c..b754e6fe 100644 --- a/client/dashboard.js +++ b/client/dashboard.js @@ -3172,10 +3172,13 @@ class Dashboard { const detailsUrl = `/api/process/telemetry/details?lookbackHours=${encodeURIComponent(String(safeHours))}&bucketMinutes=${encodeURIComponent(String(safeBucket))}`; const benchmarkUrl = `/api/process/telemetry/benchmarks?lookbackHours=${encodeURIComponent(String(safeHours))}&bucketMinutes=${encodeURIComponent(String(safeBucket))}&limit=8`; + const contextUrl = `/api/process/telemetry/context-switches?hours=${encodeURIComponent(String(safeHours))}`; + try { - const [detailsRes, benchmarkRes] = await Promise.all([ + const [detailsRes, benchmarkRes, contextRes] = await Promise.all([ fetch(detailsUrl).catch(() => null), - fetch(benchmarkUrl).catch(() => null) + fetch(benchmarkUrl).catch(() => null), + fetch(contextUrl).catch(() => null) ]); const data = detailsRes ? await detailsRes.json().catch(() => ({})) : {}; @@ -3186,7 +3189,10 @@ class Dashboard { const benchmark = benchmarkRes && benchmarkRes.ok ? await benchmarkRes.json().catch(() => null) : null; - body.innerHTML = this.renderTelemetryDetails(data, benchmark); + const contextSwitches = contextRes && contextRes.ok + ? await contextRes.json().catch(() => null) + : null; + body.innerHTML = this.renderTelemetryDetails(data, benchmark, contextSwitches); } catch { body.textContent = 'Failed to load.'; } @@ -3314,7 +3320,7 @@ class Dashboard { } } - renderTelemetryDetails(data, benchmarkData = null) { + renderTelemetryDetails(data, benchmarkData = null, contextSwitches = null) { const escapeHtml = (value) => String(value ?? '') .replace(/&/g, '&') .replace(/ +
Context switches (${escapeHtml(cs.hours)}h): ${Number(cs.switches ?? 0)}${Number(cs.estimatedCostMinutes ?? 0)}m refocus cost • review focus ${Number(cs.reviewMinutes ?? 0)}m
+ ${Array.isArray(cs.topPairs) && cs.topPairs.length ? `
Top thrash: ${cs.topPairs.slice(0, 3).map(p => `${escapeHtml(p.pair)} ×${Number(p.count)}`).join(' • ')}
` : ''} +
+ ` : ''; + return `
Bucket ${escapeHtml(bucketMinutes)}m
@@ -3422,6 +3436,7 @@ class Dashboard { ${histogram(promptHist, { formatLabel: (v) => `${Math.round(Number(v) || 0)}` })}
+ ${contextSection} ${benchmarkSection} `; } diff --git a/client/index.html b/client/index.html index f7ee998e..4855d0c7 100644 --- a/client/index.html +++ b/client/index.html @@ -13,6 +13,7 @@ + @@ -78,9 +79,8 @@

Worktrees

- - + + + +
+

UI Mode

+

Switch between the lean default UI and the full + process/workflow layer (process banner, workflow modes Focus/Review/Background, + tier filters, PRs, Review Route, Activity, Diff, dashboard process cards, + commander controls). Applies immediately and reloads the page.

+
+ + + +
+
+

Branch Labels

@@ -536,7 +560,12 @@

Notifications

+ + + + + diff --git a/client/plugin-host.js b/client/plugin-host.js index 0e80d842..0707506c 100644 --- a/client/plugin-host.js +++ b/client/plugin-host.js @@ -133,6 +133,37 @@ class OrchestratorPluginHost { return { ok: true }; } + if (type === 'post_route') { + const route = String(action.route || '').trim(); + // Same-origin, plugin-namespaced routes only (server enforces this too). + if (!route.startsWith('/') || route.startsWith('//') || !route.startsWith('/api/plugins/')) { + return { ok: false, error: 'Invalid route' }; + } + + const body = action.payload && typeof action.payload === 'object' ? { ...action.payload } : {}; + if (action.prompt) { + const value = window.prompt(String(action.prompt)); + if (value === null) return { ok: false, cancelled: true }; + body[String(action.field || 'value')] = value; + } + + try { + const res = await fetch(route, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + return { ok: false, error: String(data?.error || `Request failed (${res.status})`), data }; + } + this.emit('post-route-result', { item, data }); + return { ok: true, data }; + } catch (e) { + return { ok: false, error: String(e?.message || e) }; + } + } + return { ok: false, error: `Unsupported action type: ${type}` }; } } diff --git a/client/plugins-admin.js b/client/plugins-admin.js new file mode 100644 index 00000000..c557e9c7 --- /dev/null +++ b/client/plugins-admin.js @@ -0,0 +1,58 @@ +// Settings → Plugins: shows loaded/failed plugins and a reload button. +// Failed plugins were previously invisible — a bad manifest just vanished. +(function () { + 'use strict'; + + const esc = (v) => String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>'); + + const render = async () => { + const list = document.getElementById('plugins-admin-list'); + if (!list) return; + try { + const res = await fetch('/api/plugins'); + if (!res.ok) throw new Error(`status ${res.status}`); + const data = await res.json(); + const loaded = Array.isArray(data?.loaded) ? data.loaded : (Array.isArray(data?.plugins) ? data.plugins : []); + const failed = Array.isArray(data?.failed) ? data.failed : []; + + const loadedHtml = loaded.length + ? loaded.map((p) => `
${esc(p.name || p.id || p.pluginId)} ${esc(p.version || '')} — ${esc(p.description || '')} (${Number(p.commandCount ?? p.commands ?? 0) || 0} cmds)
`).join('') + : '
No plugins loaded.
'; + const failedHtml = failed.length + ? failed.map((p) => `
${esc(p.id || p.pluginId || '(unknown)')} — ${esc(p.error || 'failed to load')}
`).join('') + : ''; + + list.innerHTML = loadedHtml + failedHtml; + } catch (e) { + list.textContent = `Failed to load plugin status (${String(e?.message || e)})`; + } + }; + + const init = () => { + const reloadBtn = document.getElementById('plugins-reload-btn'); + if (!reloadBtn) return; + + reloadBtn.addEventListener('click', async () => { + reloadBtn.disabled = true; + reloadBtn.textContent = '🔄 Reloading…'; + try { + await fetch('/api/plugins/reload', { method: 'POST' }); + } catch { /* status re-render below reports the outcome */ } + await render(); + try { await window.orchestratorPluginHost?.refresh({ force: true }); } catch {} + reloadBtn.textContent = '🔄 Reload plugins'; + reloadBtn.disabled = false; + }); + + render(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/client/queue-evidence.js b/client/queue-evidence.js new file mode 100644 index 00000000..e2a6c2d1 --- /dev/null +++ b/client/queue-evidence.js @@ -0,0 +1,208 @@ +// Queue Evidence Card — renders the at-a-glance proof panel for a queue item: +// tests ran, app actually launched, agent review chain verdicts, screenshots, +// data/balance measurements, diff stats, standards used, handoff notes. +// Data source: task record `evidence` (see docs/agents/EVIDENCE_PROTOCOL.md). +(function () { + 'use strict'; + + const esc = (v) => String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // Evidence originates from PR bodies/comments (semi-untrusted). Entity-escaping + // alone doesn't stop a javascript:/data: URI from becoming a clickable link, so + // only plain http(s) URLs are rendered as anchors; anything else stays text. + const safeHttpUrl = (value) => { + const s = String(value || '').trim(); + return /^https?:\/\//i.test(s) ? s : ''; + }; + + const VERDICT_META = { + approved: { icon: '✅', cls: 'ok' }, + needs_fix: { icon: '🛑', cls: 'bad' }, + commented: { icon: '💬', cls: 'warn' }, + skipped: { icon: '⏭', cls: 'muted' } + }; + + const timeAgo = (iso) => { + const ms = Date.parse(String(iso || '')); + if (!Number.isFinite(ms)) return ''; + const mins = Math.max(0, Math.round((Date.now() - ms) / 60000)); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 48) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; + }; + + const badge = (cls, icon, label, title) => + `${icon} ${esc(label)}`; + + const buildBadges = (evidence) => { + const badges = []; + const tests = evidence.tests || null; + if (tests?.ran) { + const failed = Number(tests.failed) || 0; + const passed = Number(tests.passed); + const label = Number.isFinite(passed) ? `${passed}✓${failed ? ` ${failed}✗` : ''}` : (failed ? `${failed}✗` : 'ran'); + badges.push(badge(failed ? 'bad' : 'ok', '🧪', label, tests.command ? `Tests: ${tests.command}` : 'Tests ran')); + } else { + badges.push(badge('missing', '🧪', 'no tests', 'No automated test evidence')); + } + + const appRun = evidence.appRun || null; + if (appRun?.ran) { + badges.push(badge('ok', '▶️', appRun.method || 'ran', appRun.notes || 'App/game was actually launched')); + } else { + badges.push(badge('missing', '▶️', 'not run', 'No proof the app/game was launched')); + } + + const reviews = Array.isArray(evidence.reviews) ? evidence.reviews : []; + if (reviews.length) { + const approved = reviews.filter(r => r.verdict === 'approved').length; + const cls = approved === reviews.length ? 'ok' : (reviews.some(r => r.verdict === 'needs_fix') ? 'bad' : 'warn'); + badges.push(badge(cls, '🧑‍⚖️', `${approved}/${reviews.length} reviews`, reviews.map(r => `${r.role || 'review'}: ${r.verdict || '?'}`).join(', '))); + } else { + badges.push(badge('missing', '🧑‍⚖️', 'no agent review', 'No agent review chain results')); + } + + const media = Array.isArray(evidence.media) ? evidence.media : []; + if (media.length) badges.push(badge('ok', '📸', String(media.length), `${media.length} screenshot(s)/video(s)`)); + + const data = Array.isArray(evidence.data) ? evidence.data : []; + if (data.length) badges.push(badge('ok', '📊', String(data.length), `${data.length} data measurement(s)`)); + + const diff = evidence.diffStats || null; + if (diff && (diff.files || diff.additions || diff.deletions)) { + badges.push(badge('neutral', '📄', `${diff.files ?? '?'} files +${diff.additions ?? 0}/−${diff.deletions ?? 0}`, 'Diff size')); + } + + return badges.join(' '); + }; + + const renderReviews = (reviews) => { + if (!reviews.length) return ''; + const rows = reviews.map((r) => { + const meta = VERDICT_META[r.verdict] || { icon: '•', cls: 'muted' }; + const who = [r.agentId, r.model, r.effort].filter(Boolean).join(' · '); + const counts = (r.findings !== undefined || r.fixed !== undefined) + ? ` — ${r.findings ?? 0} finding(s), ${r.fixed ?? 0} fixed` + : ''; + const reviewUrl = safeHttpUrl(r.url); + const link = reviewUrl ? ` ` : ''; + return `
+ ${meta.icon} + ${esc(r.role || 'review')} + ${esc(who)}${esc(counts)}${r.at ? ` · ${esc(timeAgo(r.at))}` : ''}${link} + ${r.summary ? `
${esc(r.summary)}
` : ''} +
`; + }).join(''); + return `
Agent reviews
${rows}
`; + }; + + const renderData = (data) => { + if (!data.length) return ''; + const rows = data.map((d) => ` + ${esc(d.metric)} + ${esc(d.before ?? '—')} + ${esc(d.after ?? '—')} + ${esc(d.note || '')} + `).join(''); + return `
Data
+ ${rows}
MetricBeforeAfterNote
+
`; + }; + + const renderMedia = (media, taskId) => { + if (!media.length) return ''; + const encId = encodeURIComponent(taskId); + const thumbs = media.map((m, idx) => { + const src = `/api/process/evidence/${encId}/media/${idx}`; + const isImage = m.type === 'image' || m.type === 'gif'; + const inner = isImage + ? `${esc(m.caption || `evidence ${idx + 1}`)}` + : `
🎞 ${esc(m.caption || m.path || `media ${idx + 1}`)}
`; + return ``; + }).join(''); + return `
Media
${thumbs}
`; + }; + + const renderCard = (task, record) => { + const evidence = record && typeof record.evidence === 'object' && record.evidence ? record.evidence : null; + const inner = evidence ? ` +
${buildBadges(evidence)}
+ ${evidence.summary ? `
${esc(evidence.summary)}
` : ''} + ${evidence.tests?.ran && evidence.tests.command ? `
🧪 ${esc(evidence.tests.command)}${evidence.tests.at ? ` · ${esc(timeAgo(evidence.tests.at))}` : ''}
` : ''} + ${evidence.appRun?.ran ? `
▶️ ${esc(evidence.appRun.method || 'ran')}${evidence.appRun.url ? (safeHttpUrl(evidence.appRun.url) ? ` · ${esc(evidence.appRun.url)}` : ` · ${esc(evidence.appRun.url)}`) : ''}${evidence.appRun.notes ? ` — ${esc(evidence.appRun.notes)}` : ''}
` : ''} + ${renderReviews(Array.isArray(evidence.reviews) ? evidence.reviews : [])} + ${renderMedia(Array.isArray(evidence.media) ? evidence.media : [], task.id)} + ${renderData(Array.isArray(evidence.data) ? evidence.data : [])} + ${Array.isArray(evidence.standards) && evidence.standards.length ? `
📐 Reviewed against: ${evidence.standards.map(esc).join(', ')}
` : ''} + ${evidence.handoff?.notes ? `
Handoff notes (for the next agent)
${esc(evidence.handoff.notes)}
` : ''} +
Updated ${esc(timeAgo(evidence.updatedAt))}
+ ` : ` +
No evidence collected yet. Agents report via agent-evidence blocks in the PR body/comments or .agent-evidence.json in the worktree — see docs/agents/EVIDENCE_PROTOCOL.md.
+ `; + + return ` +
+
Evidence + +
+ ${inner} +
+ `; + }; + + const openLightbox = (src, type) => { + const overlay = document.createElement('div'); + overlay.className = 'evidence-lightbox'; + overlay.innerHTML = type === 'video' + ? `` + : `evidence media`; + // One shared close path so the document-level key listener is removed no + // matter how the lightbox is dismissed (click previously leaked it). + const onKey = (e) => { if (e.key === 'Escape') close(); }; + const close = () => { + overlay.remove(); + document.removeEventListener('keydown', onKey); + }; + overlay.addEventListener('click', close); + document.addEventListener('keydown', onKey); + document.body.appendChild(overlay); + }; + + const wire = (detailEl, task, record, { onRefresh } = {}) => { + const card = detailEl.querySelector('[data-evidence-card]'); + if (!card) return; + + const refreshBtn = card.querySelector('#queue-evidence-refresh'); + if (refreshBtn && typeof onRefresh === 'function') { + refreshBtn.addEventListener('click', async () => { + refreshBtn.disabled = true; + refreshBtn.textContent = '⟳ Refreshing…'; + try { + await onRefresh(); + } catch (e) { + refreshBtn.textContent = '⟳ Refresh failed'; + refreshBtn.disabled = false; + return; + } + }); + } + + const encId = encodeURIComponent(task.id); + card.querySelectorAll('[data-evidence-media-idx]').forEach((thumb) => { + thumb.addEventListener('click', () => { + const idx = thumb.getAttribute('data-evidence-media-idx'); + const type = thumb.getAttribute('data-evidence-media-type'); + openLightbox(`/api/process/evidence/${encId}/media/${idx}`, type === 'video' ? 'video' : 'image'); + }); + }); + }; + + window.QueueEvidence = { renderCard, wire }; +})(); diff --git a/client/queue-workflow.js b/client/queue-workflow.js new file mode 100644 index 00000000..962dd8d6 --- /dev/null +++ b/client/queue-workflow.js @@ -0,0 +1,155 @@ +// Queue Review Workflow block — pick a multi-agent review chain for a PR task, +// run it, and watch per-stage progress (role → agent/model → verdict). +// Backed by /api/process/review-workflows (config/review-workflows.json). +(function () { + 'use strict'; + + const esc = (v) => String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + let configCache = null; + + const fetchConfig = async () => { + if (configCache) return configCache; + const res = await fetch('/api/process/review-workflows'); + if (!res.ok) throw new Error('workflow config unavailable'); + configCache = await res.json(); + return configCache; + }; + + const STAGE_ICONS = { + pending: '○', + running: '◐', + done: '●', + failed: '✗', + skipped: '⏭' + }; + + const VERDICT_ICONS = { approved: '✅', needs_fix: '🛑', commented: '💬', skipped: '⏭' }; + + const RUN_LABELS = { + running: 'Running', + pending: 'Pending', + blocked_fix: 'Blocked — changes requested', + stalled: 'Stalled — stage timed out or could not spawn', + complete: 'Complete', + cancelled: 'Cancelled' + }; + + const renderStages = (run) => (run.stages || []).map((s, i) => { + const icon = STAGE_ICONS[s.status] || '○'; + const verdict = s.verdict ? ` ${VERDICT_ICONS[s.verdict] || ''}` : ''; + const who = [s.agentId, s.model].filter(Boolean).join('/'); + const active = i === (run.stageIndex || 0) && run.status === 'running'; + return `${icon} ${esc(s.role)}${verdict}`; + }).join(''); + + const renderCard = (task, record) => { + if (task.kind !== 'pr') return ''; + const run = record?.reviewWorkflow || null; + + const runHtml = run ? ` +
+
${renderStages(run)}
+
${esc(RUN_LABELS[run.status] || run.status || '')}${run.workflowId ? ` · ${esc(run.workflowId)}` : ''}
+
+ ` : ''; + + const showStart = !run || ['complete', 'cancelled', 'blocked_fix', 'stalled'].includes(run.status); + const controls = ` +
+ ${showStart ? ` + + + ` : ''} + ${run && (run.status === 'stalled') ? `` : ''} + ${run && ['running', 'pending', 'blocked_fix', 'stalled'].includes(run.status) ? `` : ''} +
+ `; + + return ` +
+
Review workflow (agent chain)
+ ${runHtml} + ${controls} +
+ `; + }; + + const wire = (detailEl, task, record, { onChanged } = {}) => { + const card = detailEl.querySelector('[data-workflow-card]'); + if (!card) return; + + const encId = encodeURIComponent(task.id); + const select = card.querySelector('#queue-wf-select'); + const startBtn = card.querySelector('#queue-wf-start'); + const advanceBtn = card.querySelector('#queue-wf-advance'); + const cancelBtn = card.querySelector('#queue-wf-cancel'); + + if (select) { + fetchConfig().then((cfg) => { + const risk = String(record?.changeRisk || record?.baseImpactRisk || '').toLowerCase(); + const defaultId = cfg.riskDefaults?.[risk] || 'standard'; + select.innerHTML = (cfg.workflows || []).map((w) => { + const stages = (w.stages || []).map(s => s.role).join(' → '); + return ``; + }).join('') || ''; + }).catch(() => { + select.innerHTML = ''; + }); + } + + const post = async (action, body) => { + const res = await fetch(`/api/process/review-workflows/${encId}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || `${action} failed`); + return data; + }; + + if (startBtn) { + startBtn.addEventListener('click', async () => { + const workflowId = select?.value; + if (!workflowId) return; + startBtn.disabled = true; + startBtn.textContent = '▶ Starting…'; + try { + const data = await post('start', { workflowId }); + if (typeof onChanged === 'function') onChanged(data.run); + } catch (e) { + startBtn.textContent = `▶ ${e.message}`.slice(0, 40); + startBtn.disabled = false; + } + }); + } + + if (advanceBtn) { + advanceBtn.addEventListener('click', async () => { + advanceBtn.disabled = true; + try { + const data = await post('advance'); + if (typeof onChanged === 'function') onChanged(data.run); + } catch { advanceBtn.disabled = false; } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', async () => { + cancelBtn.disabled = true; + try { + const data = await post('cancel'); + if (typeof onChanged === 'function') onChanged(data.run); + } catch { cancelBtn.disabled = false; } + }); + } + }; + + window.QueueWorkflow = { renderCard, wire }; +})(); diff --git a/client/styles.css b/client/styles.css index 34dc342f..a7d2d69d 100644 --- a/client/styles.css +++ b/client/styles.css @@ -10796,6 +10796,25 @@ body.dependency-onboarding-active #dependency-setup-modal { z-index: 10; } +/* Plugin tool buttons (client.slots → "commander.tools") */ +.commander-tools-strip { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.commander-tools-strip.hidden { + display: none; +} + +.commander-tool-btn { + font-size: 0.75rem; +} + .commander-btn { padding: var(--space-xs) var(--space-md); background: var(--bg-tertiary); @@ -16041,3 +16060,43 @@ body.dependency-onboarding-active #dependency-setup-modal { grid-template-columns: 1fr; } } + +/* Multi-commander tab bar */ +.commander-tabs { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem 0; + flex-wrap: wrap; + flex-shrink: 0; +} + +.commander-tabs.hidden { display: none; } + +.commander-tab { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--border-color); + border-bottom: none; + border-radius: 0.35rem 0.35rem 0 0; + background: var(--bg-tertiary, #252a33); + color: var(--text-secondary, #9aa3b2); + cursor: pointer; +} + +.commander-tab.active { + background: var(--bg-primary); + color: var(--text-primary, #fff); + border-color: var(--accent, #6f8fc0); +} + +.commander-tab-add { font-weight: 700; } + +.commander-tab-close { + opacity: 0.6; + font-size: 0.65rem; +} +.commander-tab-close:hover { opacity: 1; color: var(--accent-danger, #d4593c); } diff --git a/client/styles/queue-evidence.css b/client/styles/queue-evidence.css new file mode 100644 index 00000000..a1f016cf --- /dev/null +++ b/client/styles/queue-evidence.css @@ -0,0 +1,231 @@ +/* Evidence card in the Queue detail panel */ + +.evidence-card .tasks-detail-block-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.evidence-refresh-btn { + font-size: 0.75rem; + padding: 0.15rem 0.5rem; +} + +.evidence-badges { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0.375rem 0 0.5rem; +} + +.evidence-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + line-height: 1.4; + padding: 0.125rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border-color, #3a3f4b); + background: var(--bg-tertiary, #252a33); + color: var(--text-primary, #fff); + white-space: nowrap; +} + +.evidence-ok { border-color: #2e7d4f; background: rgba(46, 125, 79, 0.18); } +.evidence-bad { border-color: #b3452e; background: rgba(179, 69, 46, 0.2); } +.evidence-warn { border-color: #a8842c; background: rgba(168, 132, 44, 0.18); } +.evidence-missing { border-color: #555c69; color: #aab3c0; border-style: dashed; } +.evidence-neutral { border-color: #46608a; background: rgba(70, 96, 138, 0.18); } + +.evidence-summary { + font-size: 0.85rem; + margin: 0.25rem 0 0.5rem; +} + +.evidence-line { + font-size: 0.8rem; + margin: 0.25rem 0; + overflow-wrap: anywhere; +} + +.evidence-muted { color: var(--text-secondary, #9aa3b2); } + +.evidence-section { margin-top: 0.625rem; } + +.evidence-section-title { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #9aa3b2); + margin-bottom: 0.25rem; +} + +.evidence-review-row { + font-size: 0.8rem; + padding: 0.25rem 0.375rem; + border-left: 2px solid var(--border-color, #3a3f4b); + margin-bottom: 0.25rem; +} + +.evidence-review-row.evidence-ok { border-left-color: #3ba26b; background: rgba(46, 125, 79, 0.08); } +.evidence-review-row.evidence-bad { border-left-color: #d4593c; background: rgba(179, 69, 46, 0.08); } +.evidence-review-row.evidence-warn { border-left-color: #c99b34; background: rgba(168, 132, 44, 0.08); } + +.evidence-review-role { font-weight: 600; margin: 0 0.375rem; text-transform: capitalize; } +.evidence-review-meta { color: var(--text-secondary, #9aa3b2); } +.evidence-review-summary { + margin-top: 0.25rem; + color: var(--text-primary, #e8ecf2); + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 8rem; + overflow-y: auto; +} + +.evidence-media-grid { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.evidence-media-thumb { + width: 6.5rem; + height: 4.5rem; + border: 1px solid var(--border-color, #3a3f4b); + border-radius: 0.375rem; + overflow: hidden; + background: var(--bg-tertiary, #252a33); + cursor: zoom-in; + padding: 0; +} + +.evidence-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.evidence-media-file { + font-size: 0.7rem; + padding: 0.25rem; + color: var(--text-primary, #fff); + overflow: hidden; +} + +.evidence-data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.evidence-data-table th, +.evidence-data-table td { + text-align: left; + padding: 0.2rem 0.5rem 0.2rem 0; + border-bottom: 1px solid var(--border-color, #3a3f4b); + overflow-wrap: anywhere; +} + +.evidence-data-table th { + color: var(--text-secondary, #9aa3b2); + font-weight: 500; +} + +.evidence-handoff { + font-size: 0.8rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--bg-tertiary, #252a33); + border-radius: 0.375rem; + padding: 0.375rem 0.5rem; + max-height: 10rem; + overflow-y: auto; +} + +.evidence-empty { + font-size: 0.8rem; + color: var(--text-secondary, #9aa3b2); +} + +/* Full-screen opaque viewer (house rule: no darkened-overlay modals) */ +.evidence-lightbox { + position: fixed; + inset: 0; + z-index: 10000; + background: var(--bg-primary, #14171d); + display: flex; + align-items: center; + justify-content: center; + cursor: zoom-out; +} + +.evidence-lightbox img, +.evidence-lightbox video { + max-width: 92vw; + max-height: 92vh; + border-radius: 0.375rem; +} + +/* Review workflow (agent chain) block */ + +.wf-run { margin: 0.375rem 0; } + +.wf-stages { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + font-size: 0.8rem; +} + +.wf-stage { + padding: 0.125rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border-color, #3a3f4b); + background: var(--bg-tertiary, #252a33); + white-space: nowrap; + text-transform: capitalize; +} + +.wf-stage-done { border-color: #2e7d4f; background: rgba(46, 125, 79, 0.18); } +.wf-stage-failed { border-color: #b3452e; background: rgba(179, 69, 46, 0.2); } +.wf-stage-running { border-color: #46608a; background: rgba(70, 96, 138, 0.22); } +.wf-stage-skipped { opacity: 0.6; } +.wf-stage-active { outline: 1px solid #6f8fc0; } + +.wf-arrow { color: var(--text-secondary, #9aa3b2); } + +.wf-status { + font-size: 0.75rem; + margin-top: 0.25rem; + color: var(--text-secondary, #9aa3b2); +} + +.wf-status-blocked_fix { color: #e08563; } +.wf-status-stalled { color: #d3a94c; } +.wf-status-complete { color: #58b283; } + +.wf-controls { + display: flex; + align-items: center; + gap: 0.375rem; + flex-wrap: wrap; + margin-top: 0.375rem; +} + +/* Cold prompt-cache indicator in Queue detail header */ +.cache-cold-chip { + display: inline-block; + font-size: 0.7rem; + padding: 0.05rem 0.45rem; + margin-left: 0.375rem; + border-radius: 999px; + border: 1px solid #46608a; + background: rgba(70, 96, 138, 0.22); + color: #a8c4ea; + white-space: nowrap; + cursor: help; +} diff --git a/client/visibility-preset.js b/client/visibility-preset.js new file mode 100644 index 00000000..fc9d6c78 --- /dev/null +++ b/client/visibility-preset.js @@ -0,0 +1,50 @@ +// UI Mode preset switch (Settings → UI Mode): Simple ↔ Power/Process. +// Self-contained: applies via /api/user-settings/visibility-preset and +// reloads so every gated element re-evaluates. +(function () { + 'use strict'; + + const init = () => { + const group = document.getElementById('visibility-preset-group'); + if (!group) return; + + const currentEl = document.getElementById('visibility-preset-current'); + + const markCurrent = (preset) => { + group.querySelectorAll('[data-visibility-preset]').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.visibilityPreset === preset); + }); + if (currentEl) currentEl.textContent = preset ? `Current: ${preset}` : ''; + }; + + fetch('/api/user-settings/visibility-presets') + .then(r => (r.ok ? r.json() : null)) + .then((data) => { if (data?.current) markCurrent(data.current); }) + .catch(() => {}); + + group.querySelectorAll('[data-visibility-preset]').forEach((btn) => { + btn.addEventListener('click', async () => { + const preset = btn.dataset.visibilityPreset; + btn.disabled = true; + try { + const res = await fetch('/api/user-settings/visibility-preset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preset }) + }); + if (!res.ok) throw new Error('apply failed'); + window.location.reload(); + } catch { + btn.disabled = false; + if (currentEl) currentEl.textContent = 'Failed to apply preset'; + } + }); + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/config/custom-agents.example.json b/config/custom-agents.example.json new file mode 100644 index 00000000..806da27b --- /dev/null +++ b/config/custom-agents.example.json @@ -0,0 +1,54 @@ +{ + "_comment": "Copy this file to ~/.agent-workspace/custom-agents.json to register additional CLI agents (Gemini, OpenCode, Grok, aider, anything). No code changes needed: registered agents appear in the agent picker (/api/agents), can be launched from tasks/batch flows, and can be used as review-workflow stages with their own model/effort. VERIFY each command/flag against YOUR installed CLI version before enabling — these entries are documented starting points, not tested guarantees. Set \"override\": true to replace a built-in (claude/codex).", + "agents": { + "gemini": { + "name": "Gemini CLI", + "icon": "♊", + "description": "Google Gemini CLI (verify flags: gemini --help)", + "baseCommand": "gemini", + "modes": { + "fresh": { "command": "gemini", "description": "Start new session" } + }, + "flags": { + "yolo": { + "flag": "--yolo", + "label": "🚀 YOLO Mode", + "description": "Auto-approve all actions", + "category": "permissions", + "default": true + } + }, + "defaultFlags": ["yolo"], + "modelFlag": "-m {model}", + "models": ["gemini-2.5-pro", "gemini-2.5-flash"], + "defaultModel": "gemini-2.5-pro", + "initDelayMs": 10000 + }, + "opencode": { + "name": "OpenCode", + "icon": "🧩", + "description": "OpenCode TUI (verify flags: opencode --help; permissions are configured in opencode.json, not CLI flags)", + "baseCommand": "opencode", + "modes": { + "fresh": { "command": "opencode", "description": "Start new session" }, + "continue": { "command": "opencode --continue", "description": "Continue most recent session" } + }, + "flags": {}, + "defaultFlags": [], + "modelFlag": "--model {model}", + "initDelayMs": 10000 + }, + "grok": { + "name": "Grok CLI", + "icon": "⚡", + "description": "SKELETON — fill in from your installed grok CLI's --help (several 'grok cli' projects exist with different flags)", + "baseCommand": "grok", + "modes": { + "fresh": { "command": "grok", "description": "Start new session" } + }, + "flags": {}, + "defaultFlags": [], + "initDelayMs": 10000 + } + } +} diff --git a/config/review-workflows.json b/config/review-workflows.json new file mode 100644 index 00000000..554b11c6 --- /dev/null +++ b/config/review-workflows.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "comment": "Data-driven agent review chains. Chain math: p_chain = product of per-stage miss rates — a 30% single-reviewer miss rate compounds to ~9% with 2 stages and ~2.7% with 3. Override per user at ~/.agent-workspace/review-workflows.json (deep merge). agentId may be ANY registered agent — built-ins (claude/codex) or custom CLIs from ~/.agent-workspace/custom-agents.json (gemini/opencode/grok/...); model/effort use each agent's own flag syntax (claude --model aliases; codex -m + model_reasoning_effort; custom agents declare modelFlag/reasoningFlag).", + "roles": { + "general": { + "label": "General reviewer", + "focusBullets": [ + "Correctness — does the change do what the PR claims? Trace the main flow end to end.", + "Project conventions — match existing patterns, naming, file layout (read CLAUDE.md / CODEBASE_DOCUMENTATION.md first).", + "Tests — are the changes covered? Do existing tests still pass? Run them.", + "Simplicity — flag dead code, duplication, god-functions introduced by the change." + ] + }, + "security": { + "label": "Security reviewer", + "focusBullets": [ + "Injection surfaces — shell commands, SQL, HTML rendering, path traversal on any user/agent-supplied string.", + "Secrets — credentials, tokens, or private paths committed or logged.", + "Untrusted input — every new endpoint/param validated and bounded? File access constrained to expected roots?", + "Dependency risk — new packages: pinned exact versions, reputable, actually needed?", + "Permissions/escalation — anything widening what an agent or endpoint may do without a gate." + ] + }, + "performance": { + "label": "Performance reviewer", + "focusBullets": [ + "Hot paths — loops over large collections, per-frame/per-request allocations, sync I/O on request paths.", + "N+1 patterns — repeated queries/fetches/spawns that should batch.", + "Memory — caches without bounds, listeners/timers/PTYs without cleanup.", + "Measure where possible — run the relevant benchmark or a quick timing before claiming a regression." + ] + } + }, + "workflows": { + "standard": { + "label": "Standard — 1 general reviewer", + "description": "Default for low/medium-risk tier-3 work. Single general review pass.", + "stages": [ + { "role": "general", "agentId": "claude", "model": "sonnet" } + ] + }, + "hardened": { + "label": "Hardened — security, then general", + "description": "For high-risk changes. Security specialist first (codex), then a strong general pass.", + "stages": [ + { "role": "security", "agentId": "codex", "model": "gpt-5.5", "effort": "high" }, + { "role": "general", "agentId": "claude", "model": "opus" } + ] + }, + "full-gate": { + "label": "Full gate — security, performance, general", + "description": "For critical changes. Three specialist passes before a human ever looks.", + "stages": [ + { "role": "security", "agentId": "codex", "model": "gpt-5.5", "effort": "high" }, + { "role": "performance", "agentId": "claude", "model": "sonnet" }, + { "role": "general", "agentId": "claude", "model": "opus" } + ] + } + }, + "riskDefaults": { + "low": "standard", + "medium": "standard", + "high": "hardened", + "critical": "full-gate" + }, + "stageTimeoutMinutes": 45 +} diff --git a/diff-viewer/client/package-lock.json b/diff-viewer/client/package-lock.json index c327edc8..422c3461 100644 --- a/diff-viewer/client/package-lock.json +++ b/diff-viewer/client/package-lock.json @@ -12,7 +12,7 @@ "@anthropic-ai/sdk": "^0.56.0", "@monaco-editor/react": "^4.7.0", "@vitejs/plugin-react": "^5.1.3", - "axios": "^1.13.4", + "axios": "1.13.4", "dompurify": "^3.1.7", "marked": "^14.1.3", "mermaid": "^10.9.3", diff --git a/docs/agents/EVIDENCE_PROTOCOL.md b/docs/agents/EVIDENCE_PROTOCOL.md new file mode 100644 index 00000000..c2180631 --- /dev/null +++ b/docs/agents/EVIDENCE_PROTOCOL.md @@ -0,0 +1,84 @@ +# Agent Evidence Protocol + +How agents prove their work so a human can approve/merge **at a glance** from the Review Queue. The Queue's Evidence card renders exactly what you report here. + +## Why + +A finished task is only reviewable-in-minutes if it arrives with proof: tests that ran, the app actually launched, reviewer verdicts, screenshots, and before/after data. Green tests alone are weak evidence for games/UI work (automated checks catch ~30% of issues there) — visual/runtime proof is first-class, not optional. + +## How to report + +Emit a fenced `agent-evidence` block containing ONE JSON object. Three supported channels, checked in this order: + +1. **PR body or PR comment** (preferred — travels with the PR): + ```` + ```agent-evidence + { ...json... } + ``` + ```` +2. **Worktree file** `.agent-evidence.json` at the worktree root (for work without a PR yet). Put media files in `.agent-evidence/` next to it. Keep both out of the diff: add `.agent-evidence*` to `.gitignore` if untracked. +3. **Direct API** (advanced): `PUT /api/process/evidence/` with `{ "evidence": { ... } }` against the orchestrator. + +Multiple blocks merge: later blocks override scalar sections (`summary`, `tests`, `appRun`, `handoff`); `reviews`/`media`/`data`/`standards` accumulate with de-duplication. Reviewer agents append their own blocks as PR comments — never edit someone else's. + +## Schema + +```json +{ + "summary": "One-line: what was built/fixed and how it was verified.", + "tests": { + "ran": true, + "command": "npm test", + "passed": 47, + "failed": 0, + "output": "optional tail of the run (≤4000 chars)" + }, + "appRun": { + "ran": true, + "method": "puppeteer | server-smoke | studio | browser | manual", + "url": "http://172.x.x.x:5555 (if a server is up)", + "notes": "what you exercised, console errors seen (should be none)" + }, + "media": [ + { "type": "image", "path": ".agent-evidence/feature.png", "caption": "New spawn menu" } + ], + "data": [ + { "metric": "boss dps", "before": 120, "after": 90, "note": "autoplay, 3 runs avg" } + ], + "reviews": [ + { + "role": "security", "agentId": "codex", "model": "gpt-5.5", + "verdict": "approved", "summary": "No injection surfaces added.", + "findings": 2, "fixed": 2 + } + ], + "standards": ["CLAUDE.md", "docs/CODE_STANDARDS.md"], + "handoff": { + "notes": "State + next steps for a successor agent (prompt caches expire ~1h — a fresh session will start from THIS, so make it complete: branch, what's done, what's risky, exact next commands)." + } +} +``` + +Field notes: +- `tests` — report what you ACTUALLY ran. Never claim green you didn't see. If there are no tests, say so (`"ran": false`) rather than omitting. +- `appRun.method` — how the app was really exercised: `puppeteer` (headless browser, screenshots captured), `server-smoke` (started + curl'd), `studio` (Roblox Studio), `manual`, etc. +- `media.path` — relative to the worktree root; only files inside the worktree are servable. Allowed: png/jpg/jpeg/gif/webp/svg/mp4/webm/mov. +- `data` — REQUIRED for balance/tuning changes: measured before/after, not intended values. +- `reviews` — one entry per completed review stage. Verdicts: `approved` | `needs_fix` | `commented` | `skipped`. +- `diffStats` — ignored for PRs; the server computes it from GitHub. +- `standards` — the docs you (and your reviewers) checked the change against. + +## Reviewer agents + +If you are a review-chain stage (see `config/review-workflows.json`), you MUST: +1. Post your result as a PR comment containing an `agent-evidence` block with a single `reviews[]` entry (your role, verdict, findings/fixed counts, one-paragraph summary). +2. Submit the matching GitHub verdict: `gh pr review N --approve|--request-changes|--comment -b "..."`. +Read earlier stages' comments first (`gh pr view N --comments`) — verify their reported fixes instead of repeating findings. + +## Self-orchestrated chains (optional) + +An implementer agent may run its own review chain before requesting human review: spawn 1–3 read-only reviewer subagents with distinct lenses (general / security / performance), have each return findings, fix what's real, then record the chain in `reviews[]` (`"agentId": "claude", "by": "self-chain"`). Error rates multiply — two independent 30%-miss reviewers compound to ~9% — so even one extra lens materially hardens the work. Do this especially for tier-3 background tasks where the human reviews in batch. + +## Handoff notes (fresh-window reprompts) + +Anthropic prompt caches expire after ~1 hour idle. If your task may be re-prompted later (review feedback, follow-ups), keep `handoff.notes` current — the orchestrator offers a "Reprompt (fresh)" flow that seeds a NEW session from your handoff notes instead of continuing a cold one. diff --git a/plugins/README.md b/plugins/README.md index 4bbf716d..d0b6c8d7 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -61,3 +61,43 @@ module.exports = async function register({ router, registerCommand }) { - `GET /api/plugins` shows loaded/failed plugins. - `POST /api/plugins/reload` reloads plugins from disk. - Plugin routes are mounted under `/api/plugins//*`. + +## Client UI slots (`client.slots`) + +A plugin can add buttons to named UI slots via the manifest — no client JavaScript needed: + +```json +{ + "client": { + "slots": [ + { + "id": "open-board", + "slot": "commander.tools", + "label": "🎬 My Tool", + "description": "Tooltip text", + "order": 10, + "action": { "type": "post_route", "route": "/api/plugins/my-plugin/run", "prompt": "Input value:", "field": "value" } + } + ] + } +} +``` + +Slots the client currently renders: +- `commander.tools` — button strip in the Commander panel (between toolbar and terminal). +- `dashboard.telemetry.actions` — action row in the dashboard Telemetry overlay. + +Action types: +- `open_url` `{ url }` — opens an external `https?://` URL in a new tab. +- `open_route` `{ route }` — opens a local route (must start with `/`). +- `copy_text` `{ text }` — copies text to the clipboard. +- `commander_action` `{ commanderAction, payload? }` — runs a command-catalog action. +- `post_route` `{ route, prompt?, field?, payload? }` — POSTs JSON to a local route; if `prompt` is set the user is asked for one input first, sent as `field` (default `value`). The route's JSON response `message`/`error` is surfaced in the UI. + +## Example plugin + +`plugins/youtube-transcript/` is a complete working example: a `post_route` button in `commander.tools` plus a `youtube-transcript-transcribe` command that fetches a video's subtitles via `yt-dlp` and saves a plain-text transcript to `~/Downloads/transcripts/`. + +## Managing plugins + +Settings → Plugins lists loaded and **failed** plugins (a bad manifest no longer fails silently) and has a Reload button. diff --git a/plugins/youtube-transcript/plugin.json b/plugins/youtube-transcript/plugin.json new file mode 100644 index 00000000..470b7126 --- /dev/null +++ b/plugins/youtube-transcript/plugin.json @@ -0,0 +1,31 @@ +{ + "manifestVersion": 1, + "id": "youtube-transcript", + "name": "YouTube Transcript", + "version": "0.1.0", + "description": "Paste a YouTube URL, get a plain-text transcript saved locally (uses yt-dlp subtitles — no API key).", + "serverEntry": "server.js", + "capabilities": { + "routes": true, + "commands": true, + "surfaces": ["commander", "voice", "ui"] + }, + "client": { + "slots": [ + { + "id": "transcribe", + "slot": "commander.tools", + "label": "🎬 YouTube → Transcript", + "description": "Fetch subtitles for a YouTube video and save them as a plain-text transcript", + "order": 10, + "action": { + "type": "post_route", + "route": "/api/plugins/youtube-transcript/transcribe", + "prompt": "YouTube URL to transcribe:", + "field": "url" + } + } + ] + }, + "compatibility": { "minNodeVersion": "18.0.0" } +} diff --git a/plugins/youtube-transcript/server.js b/plugins/youtube-transcript/server.js new file mode 100644 index 00000000..beb092e3 --- /dev/null +++ b/plugins/youtube-transcript/server.js @@ -0,0 +1,135 @@ +'use strict'; + +const { execFile } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const YT_URL_RE = /^https?:\/\/(www\.)?(youtube\.com|youtu\.be|m\.youtube\.com)\//i; +const EXEC_TIMEOUT_MS = 180_000; + +const run = (cmd, args, options = {}) => new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: EXEC_TIMEOUT_MS, maxBuffer: 20_000_000, ...options }, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + } else { + resolve({ stdout, stderr }); + } + }); +}); + +const hasYtDlp = async () => { + try { + await run('yt-dlp', ['--version']); + return true; + } catch { + return false; + } +}; + +// VTT → plain text: drop headers/timestamps/cue settings/inline tags and the +// duplicated rolling lines that auto-generated captions produce. +const vttToText = (vtt) => { + const lines = String(vtt || '').split(/\r?\n/); + const out = []; + let last = ''; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + if (/^WEBVTT/i.test(line) || /^Kind:|^Language:/i.test(line)) continue; + if (/-->/.test(line)) continue; + if (/^\d+$/.test(line)) continue; + const text = line.replace(/<[^>]+>/g, '').trim(); + if (!text || text === last) continue; + out.push(text); + last = text; + } + return out.join('\n'); +}; + +const transcriptsDir = () => path.join(os.homedir(), 'Downloads', 'transcripts'); + +const transcribe = async (url, logger) => { + const target = String(url || '').trim(); + if (!YT_URL_RE.test(target)) { + return { ok: false, error: 'Not a YouTube URL. Expected youtube.com or youtu.be.' }; + } + + if (!(await hasYtDlp())) { + return { + ok: false, + error: 'yt-dlp is not installed. Install it (e.g. `pipx install yt-dlp` or `sudo apt-get install -y yt-dlp`) and retry.', + missingTool: 'yt-dlp' + }; + } + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'yt-transcript-')); + try { + await run('yt-dlp', [ + '--skip-download', + '--write-subs', + '--write-auto-subs', + '--sub-langs', 'en.*,en', + '--sub-format', 'vtt', + '--restrict-filenames', + '-o', path.join(workDir, '%(title)s.%(ext)s'), + target + ]); + + const vttFile = fs.readdirSync(workDir).find(f => f.endsWith('.vtt')); + if (!vttFile) { + return { ok: false, error: 'No subtitles available for this video (not even auto-generated).' }; + } + + const text = vttToText(fs.readFileSync(path.join(workDir, vttFile), 'utf8')); + if (!text.trim()) { + return { ok: false, error: 'Subtitle file was empty after cleanup.' }; + } + + const title = vttFile.replace(/\.[a-z-]+\.vtt$/i, '').replace(/\.vtt$/i, ''); + const outDir = transcriptsDir(); + fs.mkdirSync(outDir, { recursive: true }); + const outFile = path.join(outDir, `${title}.txt`); + fs.writeFileSync(outFile, `# ${title}\n# ${target}\n\n${text}\n`); + + logger?.info?.('Transcript saved', { outFile, chars: text.length }); + return { + ok: true, + file: outFile, + title, + chars: text.length, + preview: text.slice(0, 400) + }; + } catch (e) { + const detail = String(e?.stderr || e?.message || e).slice(0, 500); + return { ok: false, error: `yt-dlp failed: ${detail}` }; + } finally { + try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* temp cleanup best-effort */ } + } +}; + +module.exports = async function register({ router, registerCommand, logger }) { + router.post('/transcribe', async (req, res) => { + const result = await transcribe(req.body?.url, logger); + res.status(result.ok ? 200 : 400).json({ + ...result, + message: result.ok + ? `Transcript saved: ${result.file} (${result.chars} chars)` + : result.error + }); + }); + + registerCommand('transcribe', { + category: 'plugin', + description: 'Fetch a YouTube transcript to ~/Downloads/transcripts (yt-dlp subtitles)', + params: [{ name: 'url', description: 'YouTube video URL', required: true }], + examples: ['transcribe https://www.youtube.com/watch?v=...'], + handler: async (params = {}) => { + const result = await transcribe(params.url, logger); + if (!result.ok) throw new Error(result.error); + return { message: `Transcript saved: ${result.file} (${result.chars} chars)` }; + } + }); +}; diff --git a/server/agentManager.js b/server/agentManager.js index 35add6aa..b626f68e 100644 --- a/server/agentManager.js +++ b/server/agentManager.js @@ -1,12 +1,160 @@ /** * Agent Manager - Centralized configuration for multiple AI agents - * Supports Claude, Codex, and extensible for future agents + * Supports Claude, Codex, and any custom CLI agent registered via + * ~/.agent-workspace/custom-agents.json (see config/custom-agents.example.json). */ +const fs = require('fs'); +const path = require('path'); + +const { isSafeModel, isSafeReasoning, isSafeFlag } = require('./utils/shellSafety'); + +const CUSTOM_AGENT_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/; +// A model/reasoning flag template must contain its single placeholder and only +// shell-safe surrounding characters (double-quotes allowed — the codex default +// is `-c model_reasoning_effort="{reasoning}"`; the interpolated VALUE is +// separately validated by isSafeModel/isSafeReasoning, so quotes in the +// template itself can't introduce injection). +const MODEL_TEMPLATE_RE = /^[A-Za-z0-9 _\-=./:@,+"]*\{model\}[A-Za-z0-9 _\-=./:@,+"]*$/; +const REASONING_TEMPLATE_RE = /^[A-Za-z0-9 _\-=./:@,+"]*\{reasoning\}[A-Za-z0-9 _\-=./:@,+"]*$/; + class AgentManager { - constructor() { + constructor({ customAgentsPath } = {}) { this.agentConfigs = new Map(); + this.customAgentsPath = customAgentsPath || this.defaultCustomAgentsPath(); this.initializeAgents(); + this.loadCustomAgents(); + } + + defaultCustomAgentsPath() { + try { + const { getAgentWorkspaceDir } = require('./utils/pathUtils'); + return path.join(getAgentWorkspaceDir(), 'custom-agents.json'); + } catch { + return null; + } + } + + /** + * Merge user-defined agents (Gemini, OpenCode, Grok, aider, ...) from a + * JSON file into the registry. Everything downstream is registry-driven — + * launch flags, init delay, model/effort flag syntax, the /api/agents UI, + * review-workflow stages — so a new CLI needs zero code, only config. + * Never throws: a bad file logs and is skipped. + */ + loadCustomAgents() { + const filePath = this.customAgentsPath; + if (!filePath) return; + try { + if (!fs.existsSync(filePath)) return; + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const agents = parsed && typeof parsed === 'object' ? parsed.agents : null; + if (!agents || typeof agents !== 'object') return; + + for (const [rawId, cfg] of Object.entries(agents)) { + const id = String(rawId || '').trim().toLowerCase(); + try { + const normalized = this.normalizeCustomAgent(id, cfg); + this.agentConfigs.set(id, normalized); + } catch (e) { + console.warn(`[agentManager] Skipping custom agent '${rawId}': ${e.message}`); + } + } + } catch (e) { + console.warn(`[agentManager] Failed to load custom agents from ${filePath}: ${e.message}`); + } + } + + validatedTemplate(value, re, id, field) { + if (!value) return undefined; + const str = String(value); + if (!re.test(str)) { + throw new Error(`${field} template contains unsafe characters or lacks its placeholder`); + } + return str; + } + + normalizeCustomAgent(id, cfg) { + if (!CUSTOM_AGENT_ID_RE.test(id)) throw new Error('invalid id (lowercase letters/digits/dashes)'); + if (!cfg || typeof cfg !== 'object') throw new Error('config must be an object'); + if (this.agentConfigs.has(id) && !cfg.override) { + throw new Error(`'${id}' already exists (set "override": true to replace the built-in)`); + } + + const baseCommand = String(cfg.baseCommand || id).trim(); + if (!baseCommand) throw new Error('baseCommand is required'); + + const modes = {}; + const rawModes = cfg.modes && typeof cfg.modes === 'object' ? cfg.modes : {}; + for (const [modeId, mode] of Object.entries(rawModes)) { + const command = String(mode?.command || '').trim(); + if (!command) continue; + modes[modeId] = { command, description: String(mode?.description || '') }; + } + if (!modes.fresh) modes.fresh = { command: baseCommand, description: 'Start new session' }; + + const flags = {}; + const rawFlags = cfg.flags && typeof cfg.flags === 'object' ? cfg.flags : {}; + for (const [flagId, flag] of Object.entries(rawFlags)) { + const flagStr = String(flag?.flag || '').trim(); + if (!flagStr) continue; + // Flag strings are written to a shell; reject shell metacharacters. + if (!isSafeFlag(flagStr)) { + throw new Error(`flag '${flagId}' contains unsafe shell characters`); + } + flags[flagId] = { + flag: flagStr, + description: String(flag?.description || ''), + label: String(flag?.label || flagId), + category: String(flag?.category || 'general'), + default: !!flag?.default + }; + } + + const defaultFlags = (Array.isArray(cfg.defaultFlags) ? cfg.defaultFlags : []) + .map(f => String(f || '').trim()) + .filter(f => f && flags[f]); + + const initDelayMs = Number(cfg.initDelayMs); + + return { + id, + name: String(cfg.name || id), + icon: String(cfg.icon || '🤖'), + description: String(cfg.description || `Custom agent: ${id}`), + baseCommand, + modes, + flags, + defaultMode: modes[cfg.defaultMode] ? String(cfg.defaultMode) : 'fresh', + defaultFlags, + availableFlags: Object.keys(flags), + flagCategories: cfg.flagCategories && typeof cfg.flagCategories === 'object' ? cfg.flagCategories : {}, + models: Array.isArray(cfg.models) ? cfg.models.map(m => String(m)) : undefined, + defaultModel: cfg.defaultModel ? String(cfg.defaultModel) : undefined, + // Per-agent CLI syntax for model/effort selection, e.g. "--model {model}". + // Reject templates with shell metacharacters at load time. + modelFlag: this.validatedTemplate(cfg.modelFlag, MODEL_TEMPLATE_RE, id, 'modelFlag'), + reasoningFlag: this.validatedTemplate(cfg.reasoningFlag, REASONING_TEMPLATE_RE, id, 'reasoningFlag'), + initDelayMs: Number.isFinite(initDelayMs) && initDelayMs >= 0 ? initDelayMs : undefined, + custom: true + }; + } + + /** + * Spawn defaults used by automation (reviewer/fixer/workflow stages): + * the agent's own defaultFlags (e.g. claude → skipPermissions, codex → + * yolo) so unattended launches don't stall on approval prompts. + */ + getSpawnFlags(agentId) { + const agent = this.agentConfigs.get(agentId); + if (!agent) return []; + return Array.isArray(agent.defaultFlags) ? [...agent.defaultFlags] : []; + } + + getInitDelayMs(agentId) { + const agent = this.agentConfigs.get(agentId); + if (Number.isFinite(agent?.initDelayMs)) return agent.initDelayMs; + return agentId === 'codex' ? 15_000 : 8_000; } initializeAgents() { @@ -198,14 +346,24 @@ class AgentManager { } } - // Add model if specified (Codex) - if (config.model && agent.models) { - command += ` -m ${config.model}`; + // Add model if specified. Agents declare their own CLI syntax via + // `modelFlag` (e.g. "--model {model}"); default is codex-style `-m`. + // Both the value AND the template are validated — this string is + // written to a shell, so an unsafe model/template is dropped, not run. + if (config.model && (agent.models || agent.modelFlag)) { + const modelTemplate = agent.modelFlag || '-m {model}'; + if (isSafeModel(config.model) && MODEL_TEMPLATE_RE.test(modelTemplate)) { + command += ` ${modelTemplate.replace('{model}', config.model)}`; + } } - // Add reasoning level if specified (Codex) - if (config.reasoning) { - command += ` -c model_reasoning_effort="${config.reasoning}"`; + // Add reasoning level if the agent supports one (codex declares + // reasoningLevels; custom agents declare their own reasoningFlag). + if (config.reasoning && (agent.reasoningLevels || agent.reasoningFlag)) { + const reasoningTemplate = agent.reasoningFlag || '-c model_reasoning_effort="{reasoning}"'; + if (isSafeReasoning(config.reasoning) && REASONING_TEMPLATE_RE.test(reasoningTemplate)) { + command += ` ${reasoningTemplate.replace('{reasoning}', config.reasoning)}`; + } } // Add verbosity level if specified (Codex) diff --git a/server/agentSpawnHelper.js b/server/agentSpawnHelper.js new file mode 100644 index 00000000..0f427ff2 --- /dev/null +++ b/server/agentSpawnHelper.js @@ -0,0 +1,102 @@ +'use strict'; + +// Shared helpers for spawning a one-shot agent (reviewer/fixer/workflow stage) +// into an idle worktree terminal. Used by prReviewAutomationService and +// reviewWorkflowService so the launch mechanics live in exactly one place. +// Agent-agnostic: flags and init delays resolve from the agentManager +// registry (built-ins + ~/.agent-workspace/custom-agents.json), so any +// registered CLI agent works here — the maps below are only fallbacks. + +const AGENT_INIT_DELAY_MS = { claude: 8_000, codex: 15_000 }; +const FALLBACK_SPAWN_FLAGS = { claude: ['skipPermissions'], codex: ['yolo'] }; +const PROMPT_SUBMIT_DELAY_MS = 500; + +// Find an idle/exited agent terminal in the active workspace whose worktree +// is not in `usedWorktreeIds`. Returns { worktreeId, repoName } or null. +const findAvailableWorktree = ({ workspaceManager, sessionManager, usedWorktreeIds = new Set() } = {}) => { + if (!workspaceManager) return null; + + const activeWs = workspaceManager.getActiveWorkspace?.(); + const wsId = activeWs?.id; + if (!wsId) return null; + + const workspace = workspaceManager.getWorkspaceById?.(wsId); + if (!workspace) return null; + + const terminals = workspace.terminals || []; + for (const terminal of terminals) { + const worktreeId = terminal.worktreeId || terminal.worktree; + if (!worktreeId) continue; + if (usedWorktreeIds.has(worktreeId)) continue; + + const repoName = terminal.repository?.name || terminal.repositoryName || ''; + const claudeSessionId = `${repoName}-${worktreeId}-claude`; + const session = sessionManager?.getSessionById?.(claudeSessionId); + if (!session || session.status === 'exited' || session.status === 'idle') { + return { worktreeId, repoName }; + } + } + + return null; +}; + +// Start an agent in a session and send the prompt after the agent has had +// time to initialize. The prompt text and the submitting "\r" are separate +// writes — a trailing "\n" inside the same write is treated as pasted text +// by agent CLIs, not as submit. +const spawnAgentInSession = ({ + sessionManager, + sessionId, + agentId = 'claude', + model = null, + effort = null, + mode = 'fresh', + prompt = '' +} = {}) => { + if (!sessionManager || !sessionId) return false; + + const agentManager = sessionManager.agentManager || null; + const registryFlags = typeof agentManager?.getSpawnFlags === 'function' + ? agentManager.getSpawnFlags(agentId) + : null; + + const config = { + agentId, + mode, + flags: (registryFlags && registryFlags.length) + ? registryFlags + : (FALLBACK_SPAWN_FLAGS[agentId] || []) + }; + if (model) config.model = model; + if (effort) config.reasoning = effort; + + const started = sessionManager.startAgentWithConfig(sessionId, config); + if (!started) return false; + + const initDelay = typeof agentManager?.getInitDelayMs === 'function' + ? agentManager.getInitDelayMs(agentId) + : (AGENT_INIT_DELAY_MS[agentId] || AGENT_INIT_DELAY_MS.claude); + // Track both delayed writes so a caller that cancels its run (e.g. a review + // workflow cancelled during the init window) can stop the prompt from being + // typed into the terminal seconds after the run was reported dead. + const timers = []; + const initTimer = setTimeout(() => { + sessionManager.writeToSession(sessionId, prompt); + const submitTimer = setTimeout(() => sessionManager.writeToSession(sessionId, '\r'), PROMPT_SUBMIT_DELAY_MS); + timers.push(submitTimer); + if (typeof submitTimer?.unref === 'function') submitTimer.unref(); + }, initDelay); + timers.push(initTimer); + if (typeof initTimer?.unref === 'function') initTimer.unref(); + + return { + started: true, + cancelPendingPrompt: () => { for (const t of timers.splice(0)) clearTimeout(t); } + }; +}; + +module.exports = { + findAvailableWorktree, + spawnAgentInSession, + AGENT_INIT_DELAY_MS +}; diff --git a/server/batchLaunchService.js b/server/batchLaunchService.js index 61f473bd..6dc3d914 100644 --- a/server/batchLaunchService.js +++ b/server/batchLaunchService.js @@ -1,6 +1,8 @@ const winston = require('winston'); const path = require('path'); +const { buildEvidencePromptSnippet } = require('./evidencePromptSnippet'); + const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.json()), @@ -177,7 +179,7 @@ class BatchLaunchService { }).catch(err => logger.warn('Failed to link task record', { sessionId: claudeSessionId, error: err.message })); // 4. Start agent - const flags = (agentId === 'claude') ? ['skipPermissions'] : []; + const flags = (agentId === 'claude') ? ['skipPermissions'] : ['yolo']; const agentStarted = this.sessionManager.startAgentWithConfig(claudeSessionId, { agentId, mode: 'fresh', @@ -227,11 +229,15 @@ class BatchLaunchService { '' ].filter(Boolean).join('\n'); + const settings = this.userSettingsService?.getAllSettings?.() || {}; + const evidenceEnabled = settings?.global?.ui?.tasks?.evidencePromptEnabled !== false; + return [ globalPromptPrefix || '', boardPromptPrefix || '', preface || '', - (card.desc || '').trim() || '' + (card.desc || '').trim() || '', + evidenceEnabled ? buildEvidencePromptSnippet() : '' ].map(s => String(s || '').replace(/\s+$/, '')).filter(Boolean).join('\n\n').trim(); } diff --git a/server/commanderManager.js b/server/commanderManager.js new file mode 100644 index 00000000..e7d7febc --- /dev/null +++ b/server/commanderManager.js @@ -0,0 +1,125 @@ +'use strict'; + +// CommanderManager — holds one or more Commander instances keyed by id. +// The primary instance keeps id 'commander' so all existing single-Commander +// behavior (routes with no id → 'commander') is unchanged. Additional +// commanders let the user run a second independent orchestrating AI terminal. + +const path = require('path'); +const { CommanderService } = require('./commanderService'); +const { getAgentWorkspaceDir } = require('./utils/pathUtils'); + +const PRIMARY_ID = 'commander'; +const CUSTOM_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; +const MAX_COMMANDERS = 6; + +class CommanderManager { + constructor(options = {}) { + this.io = options.io; + this.sessionManager = options.sessionManager; + this.instances = new Map(); + // Eagerly create the primary so getInstance()-era callers keep working. + this.instances.set(PRIMARY_ID, new CommanderService({ + io: this.io, + sessionManager: this.sessionManager, + id: PRIMARY_ID + })); + } + + static getInstance(options = {}) { + if (!CommanderManager.instance) { + CommanderManager.instance = new CommanderManager(options); + } + return CommanderManager.instance; + } + + primary() { + return this.instances.get(PRIMARY_ID); + } + + // Resolve an instance by id, defaulting to the primary. Never auto-creates + // arbitrary ids from request input — unknown ids fall back to primary so a + // stray/legacy client can't spawn ghost commanders. Explicit creation goes + // through spawn(). + resolve(id) { + const key = String(id || '').trim(); + if (!key || key === PRIMARY_ID) return this.primary(); + return this.instances.get(key) || this.primary(); + } + + has(id) { + return this.instances.has(String(id || '').trim()); + } + + // Per-instance working directory: the primary uses the service default; + // additional commanders get their own dir under the app data folder so they + // can carry a distinct CLAUDE.md persona without colliding. + cwdForId(id) { + if (id === PRIMARY_ID) return undefined; + try { + return path.join(getAgentWorkspaceDir(), 'commanders', id); + } catch { + return undefined; + } + } + + spawn(rawId) { + const id = String(rawId || '').trim().toLowerCase(); + if (!CUSTOM_ID_RE.test(id)) { + throw new Error('Invalid commander id (lowercase letters/digits/dashes, max 32 chars)'); + } + if (id === PRIMARY_ID) { + throw new Error(`'${PRIMARY_ID}' is the primary commander and always exists`); + } + if (this.instances.has(id)) return this.instances.get(id); + if (this.instances.size >= MAX_COMMANDERS) { + throw new Error(`Commander limit reached (${MAX_COMMANDERS})`); + } + + const instance = new CommanderService({ + io: this.io, + sessionManager: this.sessionManager, + id, + cwd: this.cwdForId(id) + }); + this.instances.set(id, instance); + return instance; + } + + async remove(rawId) { + const id = String(rawId || '').trim(); + if (id === PRIMARY_ID) { + throw new Error('The primary commander cannot be removed'); + } + const instance = this.instances.get(id); + if (!instance) return { removed: false }; + try { + instance.stop(); + } catch { + // best-effort teardown + } + this.instances.delete(id); + return { removed: true }; + } + + list() { + return Array.from(this.instances.entries()).map(([id, instance]) => { + let status = null; + try { + status = instance.getStatus ? instance.getStatus() : null; + } catch { + status = null; + } + return { + id, + primary: id === PRIMARY_ID, + running: !!instance.session, + ready: !!instance.isReady, + claudeStarted: !!instance.claudeStarted, + status + }; + }); + } +} + +module.exports = { CommanderManager, PRIMARY_ID, MAX_COMMANDERS }; diff --git a/server/commanderService.js b/server/commanderService.js index bdd86108..751bba5f 100644 --- a/server/commanderService.js +++ b/server/commanderService.js @@ -149,6 +149,10 @@ class CommanderService { constructor(options = {}) { this.io = options.io; this.sessionManager = options.sessionManager; + // Instance identity: the default/primary Commander keeps id 'commander' + // so existing single-Commander behavior is byte-for-byte unchanged. + this.id = options.id || 'commander'; + this.cwd = options.cwd || COMMANDER_CWD; this.session = null; this.outputBuffer = ''; this.maxBufferChars = 200000; @@ -164,6 +168,13 @@ class CommanderService { return CommanderService.instance; } + // Socket payloads carry commanderId so the client can route output to the + // right panel. The primary instance still emits the same event names, and + // existing listeners simply ignore the extra field. + emit(event, payload = {}) { + if (this.io) this.io.emit(event, { ...payload, commanderId: this.id }); + } + /** * Start the Commander terminal session * This spawns a Claude Code instance from the orchestrator directory @@ -175,7 +186,7 @@ class CommanderService { } seedCommanderInstructionsIfNeeded(); - logger.info('Starting Commander terminal', { cwd: COMMANDER_CWD }); + logger.info('Starting Commander terminal', { commanderId: this.id, cwd: this.cwd }); try { if (!pty) { @@ -215,7 +226,7 @@ class CommanderService { name: 'xterm-color', cols: 120, rows: 40, - cwd: COMMANDER_CWD, + cwd: this.cwd, env }; @@ -223,7 +234,7 @@ class CommanderService { const ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); this.session = { - id: 'commander', + id: this.id, pty: ptyProcess, type: 'commander', status: 'starting', @@ -244,9 +255,7 @@ class CommanderService { this.handleClaudeLaunchOutput(data); // Emit to Commander panel - if (this.io) { - this.io.emit('commander-output', { data }); - } + this.emit('commander-output', { data }); // Detect when shell is ready if (data.includes('>') || data.includes('$')) { @@ -273,9 +282,7 @@ class CommanderService { this.isReady = false; this.claudeStarted = false; // Reset for next start this.resetClaudeLaunchState(); - if (this.io) { - this.io.emit('commander-exit', { exitCode }); - } + this.emit('commander-exit', { exitCode }); }); return { success: true, message: 'Commander terminal started' }; diff --git a/server/contextSwitchTelemetryService.js b/server/contextSwitchTelemetryService.js new file mode 100644 index 00000000..06a15d48 --- /dev/null +++ b/server/contextSwitchTelemetryService.js @@ -0,0 +1,164 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const winston = require('winston'); + +const { getAgentWorkspaceDir } = require('./utils/pathUtils'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [ + new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'context-telemetry.log'), maxsize: 1_000_000, maxFiles: 1 }) + ] +}); + +// Local-only context-switch log (JSONL). The research's Context Tax law: +// each switch costs 5-15 minutes of refocus time (10 used as the default +// estimator here). Nothing leaves the machine. + +const EVENT_TYPES = new Set([ + 'worktree-focus', + 'workspace-switch', + 'workflow-mode', + 'review-start', + 'review-end', + 'panel-open' +]); + +const DEFAULT_COST_MINUTES = 10; +const MAX_FILE_BYTES = 5_000_000; + +class ContextSwitchTelemetryService { + constructor({ filePath, costMinutesPerSwitch } = {}) { + this.filePath = filePath || path.join(getAgentWorkspaceDir(), 'telemetry', 'context-switches.jsonl'); + this.costMinutesPerSwitch = Number(costMinutesPerSwitch) > 0 ? Number(costMinutesPerSwitch) : DEFAULT_COST_MINUTES; + this._lastEventByType = new Map(); + } + + static getInstance(deps = {}) { + if (!ContextSwitchTelemetryService.instance) { + ContextSwitchTelemetryService.instance = new ContextSwitchTelemetryService(deps); + } + return ContextSwitchTelemetryService.instance; + } + + track({ type, from, to, meta } = {}) { + const t = String(type || '').trim().toLowerCase(); + if (!EVENT_TYPES.has(t)) { + return { ok: false, error: `Unknown event type: ${t}` }; + } + + const event = { + at: new Date().toISOString(), + type: t, + from: String(from || '').slice(0, 300) || null, + to: String(to || '').slice(0, 300) || null + }; + if (meta && typeof meta === 'object') { + event.meta = JSON.parse(JSON.stringify(meta)); + } + + // De-bounce identical repeats within 5s (double-fired UI handlers). + const last = this._lastEventByType.get(t); + if (last && last.to === event.to && last.from === event.from + && Date.now() - Date.parse(last.at) < 5_000) { + return { ok: true, deduped: true }; + } + this._lastEventByType.set(t, event); + + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + this._rotateIfNeeded(); + fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n'); + return { ok: true }; + } catch (e) { + logger.warn('Failed to append context-switch event', { error: e.message }); + return { ok: false, error: e.message }; + } + } + + _rotateIfNeeded() { + try { + const stat = fs.existsSync(this.filePath) ? fs.statSync(this.filePath) : null; + if (stat && stat.size > MAX_FILE_BYTES) { + fs.renameSync(this.filePath, `${this.filePath}.1`); + } + } catch { + // rotation is best-effort + } + } + + _readEvents({ sinceMs } = {}) { + const events = []; + try { + if (!fs.existsSync(this.filePath)) return events; + const lines = fs.readFileSync(this.filePath, 'utf8').split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + const at = Date.parse(event.at || '') || 0; + if (sinceMs && at < sinceMs) continue; + events.push(event); + } catch { + // skip malformed lines + } + } + } catch (e) { + logger.warn('Failed to read context-switch events', { error: e.message }); + } + return events; + } + + getSummary({ hours = 24 } = {}) { + const h = Math.min(24 * 30, Math.max(1, Number(hours) || 24)); + const sinceMs = Date.now() - h * 3_600_000; + const events = this._readEvents({ sinceMs }); + + // Only actual context CHANGES count toward the tax, not review timers. + const switchTypes = new Set(['worktree-focus', 'workspace-switch', 'workflow-mode', 'panel-open']); + const switches = events.filter(e => switchTypes.has(e.type) && e.from !== e.to); + + const byType = {}; + for (const e of events) { + byType[e.type] = (byType[e.type] || 0) + 1; + } + + const pairCounts = new Map(); + for (const e of switches) { + if (!e.from || !e.to) continue; + const key = `${e.from} → ${e.to}`; + pairCounts.set(key, (pairCounts.get(key) || 0) + 1); + } + const topPairs = [...pairCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([pair, count]) => ({ pair, count })); + + // Review focus time from paired review-start/review-end events. + let reviewMinutes = 0; + let openStart = null; + for (const e of events) { + if (e.type === 'review-start') openStart = Date.parse(e.at) || null; + else if (e.type === 'review-end' && openStart) { + reviewMinutes += Math.max(0, (Date.parse(e.at) - openStart) / 60000); + openStart = null; + } + } + + return { + hours: h, + totalEvents: events.length, + switches: switches.length, + estimatedCostMinutes: Math.round(switches.length * this.costMinutesPerSwitch), + costMinutesPerSwitch: this.costMinutesPerSwitch, + byType, + topPairs, + reviewMinutes: Math.round(reviewMinutes) + }; + } +} + +module.exports = { ContextSwitchTelemetryService }; diff --git a/server/evidencePromptSnippet.js b/server/evidencePromptSnippet.js new file mode 100644 index 00000000..9d937f85 --- /dev/null +++ b/server/evidencePromptSnippet.js @@ -0,0 +1,33 @@ +'use strict'; + +// Compact evidence-protocol instructions appended to agent launch prompts. +// Full reference: docs/agents/EVIDENCE_PROTOCOL.md (agents in other repos +// can't read that file, so this snippet must stand alone). + +const EXAMPLE = { + summary: 'What was built and how it was verified', + tests: { ran: true, command: 'npm test', passed: 47, failed: 0 }, + appRun: { ran: true, method: 'puppeteer | server-smoke | studio | manual', notes: 'what you exercised; console errors seen (should be none)' }, + media: [{ type: 'image', path: '.agent-evidence/feature.png', caption: '...' }], + data: [{ metric: 'only for balance/tuning changes', before: 0, after: 0, note: 'measured, not intended' }], + standards: ['CLAUDE.md'], + handoff: { notes: 'state + exact next steps for a successor agent (fresh sessions start from this)' } +}; + +const buildEvidencePromptSnippet = () => [ + '--- EVIDENCE PROTOCOL (required before requesting review) ---', + 'Prove your work so it can be approved at a glance. After creating/updating the PR, post ONE fenced block in the PR description or as a PR comment:', + '', + '```agent-evidence', + JSON.stringify(EXAMPLE, null, 2), + '```', + '', + 'Rules:', + '- Actually RUN the tests and the app before reporting. Never claim green you did not see.', + '- Save screenshots/video into .agent-evidence/ inside your worktree (git-ignore it) and list them under media.', + '- Balance/tuning changes need measured before/after numbers under data.', + '- No PR yet? Write the same JSON to .agent-evidence.json at the worktree root instead.', + '--- END EVIDENCE PROTOCOL ---' +].join('\n'); + +module.exports = { buildEvidencePromptSnippet }; diff --git a/server/evidenceService.js b/server/evidenceService.js new file mode 100644 index 00000000..4e0a8638 --- /dev/null +++ b/server/evidenceService.js @@ -0,0 +1,411 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const winston = require('winston'); + +const { normalizeEvidence } = require('./taskRecordService'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [ + new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'evidence.log'), maxsize: 2_000_000, maxFiles: 2 }) + ] +}); + +const EVIDENCE_FENCE_RE = /```agent-evidence\s*\n([\s\S]*?)```/g; +const MAX_BLOCKS_PER_TEXT = 10; +const MAX_BLOCK_CHARS = 20_000; +const WORKTREE_EVIDENCE_FILE = '.agent-evidence.json'; +const MEDIA_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.mp4', '.webm', '.mov']); + +const PR_ID_RE = /^pr:([^/]+)\/([^#]+)#(\d+)$/; + +const parsePrTaskId = (taskId) => { + const match = String(taskId || '').match(PR_ID_RE); + if (!match) return null; + return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }; +}; + +// Fields agents must not control: the server decides where media may be +// served from, so a crafted evidence block cannot point the media endpoint +// at an arbitrary directory. +const SERVER_ONLY_KEYS = new Set(['worktreePath']); + +const stripServerOnlyKeys = (obj) => { + if (!obj || typeof obj !== 'object') return obj; + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (SERVER_ONLY_KEYS.has(k)) continue; + out[k] = v; + } + return out; +}; + +const parseEvidenceBlocks = (text) => { + const blocks = []; + if (!text || typeof text !== 'string') return blocks; + let match; + EVIDENCE_FENCE_RE.lastIndex = 0; + while ((match = EVIDENCE_FENCE_RE.exec(text)) !== null && blocks.length < MAX_BLOCKS_PER_TEXT) { + const body = String(match[1] || '').trim(); + if (!body || body.length > MAX_BLOCK_CHARS) continue; + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + blocks.push(stripServerOnlyKeys(parsed)); + } + } catch { + // Malformed JSON inside a fence is ignored; agents get feedback via + // the evidence card showing nothing rather than a hard failure. + } + } + return blocks; +}; + +const dedupeBy = (items, keyFn) => { + const seen = new Set(); + const out = []; + for (const item of items) { + const key = keyFn(item); + if (seen.has(key)) continue; + seen.add(key); + out.push(item); + } + return out; +}; + +// Later blocks win for scalar sections; list sections accumulate with de-dupe. +const mergeEvidence = (...blocks) => { + const merged = {}; + const reviews = []; + const media = []; + const data = []; + const standards = []; + + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + // worktreePath only ever comes from trusted inputs (our own store or the + // server-side resolver) — agent-supplied blocks have it stripped upstream. + for (const key of ['summary', 'tests', 'appRun', 'handoff', 'diffStats', 'worktreePath']) { + if (block[key] !== undefined && block[key] !== null) merged[key] = block[key]; + } + if (Array.isArray(block.reviews)) reviews.push(...block.reviews); + if (Array.isArray(block.media)) media.push(...block.media); + if (Array.isArray(block.data)) data.push(...block.data); + if (Array.isArray(block.standards)) standards.push(...block.standards); + } + + if (reviews.length) { + merged.reviews = dedupeBy(reviews.filter(r => r && typeof r === 'object'), (r) => + [r.role || '', r.by || '', r.at || '', r.verdict || ''].join('|')); + } + if (media.length) { + merged.media = dedupeBy(media.filter(m => m && typeof m === 'object'), (m) => String(m.path || '')); + } + if (data.length) { + merged.data = dedupeBy(data.filter(d => d && typeof d === 'object'), (d) => + [d.metric || '', d.note || ''].join('|')); + } + if (standards.length) { + merged.standards = [...new Set(standards.map(s => String(s || '').trim()).filter(Boolean))]; + } + + return Object.keys(merged).length ? merged : null; +}; + +class EvidenceService { + constructor({ taskRecordService, pullRequestService, gitHelper, workspaceManager } = {}) { + this.taskRecordService = taskRecordService || null; + this.pullRequestService = pullRequestService || null; + this.gitHelper = gitHelper || null; + this.workspaceManager = workspaceManager || null; + } + + static getInstance(deps = {}) { + if (!EvidenceService.instance) { + EvidenceService.instance = new EvidenceService(deps); + } + return EvidenceService.instance; + } + + // ------------------------------------------------------------------------- + // Worktree source + // ------------------------------------------------------------------------- + + readWorktreeEvidence(worktreePath) { + const root = String(worktreePath || '').trim(); + if (!root) return null; + const file = path.join(root, WORKTREE_EVIDENCE_FILE); + try { + if (!fs.existsSync(file)) return null; + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 200_000) return null; + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return stripServerOnlyKeys(parsed); + } + return null; + } catch (e) { + logger.warn('Failed to read worktree evidence', { worktreePath: root, error: e.message }); + return null; + } + } + + // ------------------------------------------------------------------------- + // PR source + // ------------------------------------------------------------------------- + + async collectForPr(taskId) { + const parsed = parsePrTaskId(taskId); + if (!parsed || !this.pullRequestService) return { blocks: [], diffStats: null, headRefName: null }; + + const prUrl = `https://github.com/${parsed.owner}/${parsed.repo}/pull/${parsed.number}`; + const blocks = []; + let diffStats = null; + let headRefName = null; + + try { + const [details, prBody] = await Promise.all([ + this.pullRequestService.getPullRequestDetailsByUrl(prUrl, { maxFiles: 2000, maxComments: 200, maxReviews: 100 }), + this.pullRequestService.getPullRequest({ ...parsed, fields: ['body'] }).catch(() => null) + ]); + + if (prBody?.body) blocks.push(...parseEvidenceBlocks(prBody.body)); + const comments = details?.conversation?.issueComments || []; + for (const comment of comments) { + blocks.push(...parseEvidenceBlocks(comment?.body || '')); + } + const reviews = details?.conversation?.reviews || []; + for (const review of reviews) { + blocks.push(...parseEvidenceBlocks(review?.body || '')); + } + + const files = Array.isArray(details?.files) ? details.files : []; + if (files.length) { + diffStats = { + files: files.length, + additions: files.reduce((sum, f) => sum + (Number.isFinite(f?.additions) ? f.additions : 0), 0), + deletions: files.reduce((sum, f) => sum + (Number.isFinite(f?.deletions) ? f.deletions : 0), 0) + }; + } + headRefName = details?.pr?.headRefName || null; + } catch (e) { + logger.warn('Failed to collect PR evidence', { taskId, error: e.message }); + } + + return { blocks, diffStats, headRefName }; + } + + // ------------------------------------------------------------------------- + // Worktree discovery (trusted paths only) + // ------------------------------------------------------------------------- + + listWorkspaceWorktreePaths() { + const paths = []; + try { + const active = this.workspaceManager?.getActiveWorkspace?.(); + const workspace = active?.id ? this.workspaceManager?.getWorkspaceById?.(active.id) : null; + const terminals = workspace?.terminals || []; + for (const terminal of terminals) { + const repoPath = terminal?.repository?.path || terminal?.repositoryPath || ''; + const worktreeId = terminal?.worktreeId || terminal?.worktree || ''; + if (!repoPath || !worktreeId) continue; + const full = path.join(String(repoPath), String(worktreeId)); + if (!paths.includes(full)) paths.push(full); + } + } catch (e) { + logger.warn('Failed to list workspace worktrees', { error: e.message }); + } + return paths; + } + + async findWorktreeForBranch(branch) { + const wanted = String(branch || '').trim(); + if (!wanted || !this.gitHelper) return null; + for (const worktreePath of this.listWorkspaceWorktreePaths()) { + try { + const current = await this.gitHelper.getCurrentBranch(worktreePath); + if (current && String(current).trim() === wanted) return worktreePath; + } catch { + // ignore unreadable worktrees + } + } + return null; + } + + // A candidate media root is only trusted if it resolves (via realpath, so + // symlinks can't disguise it) to a worktree the orchestrator actually + // manages. This is what makes the media endpoint safe: an explicit + // worktreePath from the request body, or a path embedded in a task id, + // cannot point the file server at an arbitrary directory. + isKnownWorktreePath(candidate) { + const target = String(candidate || '').trim(); + if (!target) return false; + let realTarget; + try { + realTarget = fs.realpathSync(target); + } catch { + return false; + } + for (const known of this.listWorkspaceWorktreePaths()) { + try { + if (fs.realpathSync(known) === realTarget) return true; + } catch { + // unreadable known worktree — skip + } + } + return false; + } + + resolveWorktreePathForTask(taskId, explicitPath) { + const explicit = String(explicitPath || '').trim(); + if (explicit) { + // Untrusted request-body input: only honor it if it resolves to a + // worktree the orchestrator actually manages (finding #4). + return this.isKnownWorktreePath(explicit) ? explicit : null; + } + // The `worktree:` id IS the task's identity (assigned by the + // orchestrator, not free request input); trust it as the root. The media + // endpoint still realpath-confines every read to within it (finding #3). + const match = String(taskId || '').match(/^worktree:(.+)$/); + if (match) return match[1]; + return null; + } + + // ------------------------------------------------------------------------- + // Refresh: collect from all sources, merge, persist on the task record + // ------------------------------------------------------------------------- + + async refresh(taskId, { worktreePath } = {}) { + if (!taskId) throw new Error('taskId is required'); + if (!this.taskRecordService) throw new Error('taskRecordService not available'); + + const sources = []; + const blocks = []; + let diffStats = null; + let resolvedWorktree = this.resolveWorktreePathForTask(taskId, worktreePath); + + if (parsePrTaskId(taskId)) { + const pr = await this.collectForPr(taskId); + if (pr.blocks.length) sources.push({ source: 'pr', blocks: pr.blocks.length }); + blocks.push(...pr.blocks); + diffStats = pr.diffStats; + if (!resolvedWorktree && pr.headRefName) { + resolvedWorktree = await this.findWorktreeForBranch(pr.headRefName); + } + } + + if (resolvedWorktree) { + const fileEvidence = this.readWorktreeEvidence(resolvedWorktree); + if (fileEvidence) { + sources.push({ source: 'worktree-file', path: resolvedWorktree }); + blocks.push(fileEvidence); + } + } + + const existing = this.taskRecordService.get(taskId)?.evidence || null; + const merged = mergeEvidence(existing, ...blocks); + if (!merged && !diffStats) { + return { taskId, evidence: existing, sources, updated: false }; + } + + const evidencePatch = merged || {}; + if (diffStats) evidencePatch.diffStats = diffStats; + if (resolvedWorktree) evidencePatch.worktreePath = resolvedWorktree; + + const record = await this.taskRecordService.upsert(taskId, { evidence: evidencePatch }); + return { taskId, evidence: record.evidence || null, sources, updated: true }; + } + + async setDirect(taskId, evidence) { + if (!taskId) throw new Error('taskId is required'); + if (!this.taskRecordService) throw new Error('taskRecordService not available'); + const existing = this.taskRecordService.get(taskId)?.evidence || null; + const merged = evidence === null ? null : mergeEvidence(existing, stripServerOnlyKeys(evidence)); + if (merged && existing?.worktreePath) merged.worktreePath = existing.worktreePath; + const record = await this.taskRecordService.upsert(taskId, { evidence: merged }); + return record.evidence || null; + } + + // ------------------------------------------------------------------------- + // Media resolution (path-validated streaming support) + // ------------------------------------------------------------------------- + + resolveMediaPath(taskId, index) { + const record = this.taskRecordService?.get?.(taskId); + const evidence = record?.evidence; + const media = Array.isArray(evidence?.media) ? evidence.media : []; + const idx = Number(index); + if (!Number.isInteger(idx) || idx < 0 || idx >= media.length) { + return { error: 'media index out of range', status: 404 }; + } + + const root = String(evidence?.worktreePath || '').trim(); + if (!root) return { error: 'no trusted worktree path recorded for this evidence', status: 400 }; + + // The recorded worktreePath may have arrived via the generic task-record + // PUT (raw request body), not only the validated evidence endpoints — so + // re-check it against the managed-worktree set at read time. Without this, + // a crafted record could point the media server at any readable directory. + if (!this.isKnownWorktreePath(root)) { + return { error: 'evidence worktree is not managed by this orchestrator', status: 403 }; + } + + const rawPath = String(media[idx]?.path || ''); + if (!rawPath) return { error: 'media entry has no path', status: 404 }; + + const rootResolved = path.resolve(root); + const abs = path.resolve(rootResolved, rawPath); + + // 1) Lexical containment (no filesystem needed): rejects `../` traversal. + if (abs !== rootResolved && !abs.startsWith(rootResolved + path.sep)) { + return { error: 'media path escapes the evidence worktree', status: 403 }; + } + + // 2) Extension allowlist. + const ext = path.extname(abs).toLowerCase(); + if (!MEDIA_EXTENSIONS.has(ext)) { + return { error: `media extension not allowed: ${ext || '(none)'}`, status: 415 }; + } + + // 3) Realpath containment: a symlink INSIDE the worktree pointing OUT + // (e.g. .agent-evidence/leak.png -> ~/.ssh/id_rsa) passes steps 1-2 but + // must not be served. Resolve both through realpath and re-check. + let realRoot; + try { + realRoot = fs.realpathSync(rootResolved); + } catch { + return { error: 'evidence worktree no longer exists', status: 404 }; + } + let realTarget; + try { + realTarget = fs.realpathSync(abs); + } catch { + return { error: 'media file not found', status: 404 }; + } + if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) { + return { error: 'media path escapes the evidence worktree', status: 403 }; + } + + // 4) Must be a regular file. + let stat; + try { + stat = fs.lstatSync(realTarget); + } catch { + return { error: 'media file not found', status: 404 }; + } + if (!stat.isFile()) { + return { error: 'media target is not a regular file', status: 415 }; + } + + return { path: realTarget }; + } +} + +module.exports = { + EvidenceService, + parseEvidenceBlocks, + mergeEvidence +}; diff --git a/server/index.js b/server/index.js index 347b7a62..1504e4bb 100644 --- a/server/index.js +++ b/server/index.js @@ -111,7 +111,7 @@ const { ContinuityService } = require('./continuityService'); const { QuickLinksService } = require('./quickLinksService'); const { RecommendationsService } = require('./recommendationsService'); const { ProductLauncherService } = require('./productLauncherService'); -const { CommanderService } = require('./commanderService'); +const { CommanderManager } = require('./commanderManager'); const { ConversationService } = require('./conversationService'); const { AgentProviderService } = require('./agentProviderService'); const { WorktreeMetadataService } = require('./worktreeMetadataService'); @@ -139,6 +139,12 @@ const { TaskTicketingService } = require('./taskTicketingService'); const { TaskTicketMoveService } = require('./taskTicketMoveService'); const { PrMergeAutomationService } = require('./prMergeAutomationService'); const { PrReviewAutomationService } = require('./prReviewAutomationService'); +const { EvidenceService } = require('./evidenceService'); +const { ReviewWorkflowService } = require('./reviewWorkflowService'); +const visibilityPresets = require('./visibilityPresetService'); +const { ContextSwitchTelemetryService } = require('./contextSwitchTelemetryService'); +const contextSwitchTelemetry = ContextSwitchTelemetryService.getInstance(); +const { resolveServerLaunchCommand } = require('./serverLaunchCommandResolver'); const { GitHubRepoService } = require('./githubRepoService'); const { GitHubCloneWorktreeService } = require('./githubCloneWorktreeService'); const { TestOrchestrationService } = require('./testOrchestrationService'); @@ -404,10 +410,18 @@ const policyBundleService = PolicyBundleService.getInstance({ policyService, use const configPromoterService = ConfigPromoterService.getInstance({ logger }); // Initialize Commander service (Top-Level AI as Claude Code terminal) -const commanderService = CommanderService.getInstance({ +const commanderManager = CommanderManager.getInstance({ sessionManager, io }); +// The primary commander preserves the exact prior single-Commander behavior; +// `commanderService` is kept as an alias so existing route handlers that don't +// need multi-commander are untouched. Handlers that DO support a second +// commander resolve via commanderFor(req). +const commanderService = commanderManager.primary(); +const commanderFor = (req) => commanderManager.resolve( + req?.query?.commanderId || req?.body?.commanderId || req?.params?.commanderId +); // Initialize Command Registry for Commander UI control commandRegistry.init({ @@ -658,7 +672,7 @@ io.on('connection', (socket) => { // Clear any existing input first with Ctrl+C, then send command sessionManager.writeToSession(sessionId, '\x03'); // Ctrl+C to clear - setTimeout(() => { + setTimeout(async () => { // Build command with NODE_ENV and custom settings const nodeEnv = environment === 'production' ? 'production' : 'development'; @@ -674,24 +688,31 @@ io.on('connection', (socket) => { Object.assign(env, parseEnvAssignments(launchSettings.envVars)); } - // Build command (cross-shell). // Use NODE_OPTIONS for node flags so this works on both bash and PowerShell. // (Avoids bash-only `$(which hytopia)` and Windows `.cmd` wrapper issues.) - let runCommand = 'hytopia start'; const nodeOptions = String(launchSettings?.nodeOptions || '').trim(); if (nodeOptions) { env.NODE_OPTIONS = nodeOptions; } - const gameArgs = String(launchSettings?.gameArgs || '').trim(); - if (gameArgs) { - runCommand += ` ${gameArgs}`; - } - const cwd = session?.config?.cwd || null; - const command = buildShellCommand({ shellKind, cwd, env, command: runCommand }) + '\n'; - logger.info('Starting server with command', { sessionId, command, port, nodeEnv, repoPath, worktreeId }); + // Launch command comes from the cascaded config (serverCommand + + // gameModes/commonFlags templating), not a hardcoded binary. + const resolved = await resolveServerLaunchCommand({ + workspaceManager, + sessionId, + cwd, + environment, + launchSettings + }); + + const command = buildShellCommand({ shellKind, cwd, env, command: resolved.command }) + '\n'; + + logger.info('Starting server with command', { + sessionId, command, port, nodeEnv, repoPath, worktreeId, + repositoryType: resolved.repositoryType, gameMode: resolved.usedGameMode + }); const written = sessionManager.writeToSession(sessionId, command); if (!written) { @@ -3888,6 +3909,24 @@ const prReviewAutomationService = PrReviewAutomationService.getInstance({ io }); +const evidenceService = EvidenceService.getInstance({ + taskRecordService, + pullRequestService, + gitHelper, + workspaceManager +}); + +const reviewWorkflowService = ReviewWorkflowService.getInstance({ + taskRecordService, + pullRequestService, + sessionManager, + workspaceManager, + evidenceService, + io +}); +// Resume polling for any runs that were mid-flight when the server restarted. +if (reviewWorkflowService.listActiveRuns().length) reviewWorkflowService.startPolling(); + // Register pr-review-poll command so the scheduler can invoke it commandRegistry.register('pr-review-poll', { category: 'process', @@ -4086,6 +4125,27 @@ app.get('/api/user-settings', (req, res) => { } }); +// Visibility presets: one-click Simple ↔ Power/Process UI switch. +app.get('/api/user-settings/visibility-presets', (req, res) => { + try { + const current = userSettingsService.getAllSettings()?.global?.ui?.visibilityPreset || 'simple'; + res.json({ presets: visibilityPresets.listPresets(), current }); + } catch (error) { + res.status(500).json({ error: 'Failed to list visibility presets' }); + } +}); + +app.post('/api/user-settings/visibility-preset', express.json(), (req, res) => { + try { + const result = visibilityPresets.applyPreset(userSettingsService, String(req.body?.preset || '')); + activityFeed.track('settings.visibility-preset', { preset: result.preset }); + res.json(result); + } catch (error) { + logger.error('Failed to apply visibility preset', { error: error.message }); + res.status(400).json({ error: error.message || 'Failed to apply visibility preset' }); + } +}); + // Update global settings app.put('/api/user-settings/global', express.json(), (req, res) => { try { @@ -6024,6 +6084,27 @@ app.get('/api/process/telemetry', async (req, res) => { } }); +// Context-switch telemetry (local JSONL, never leaves the machine). +app.post('/api/process/telemetry/context-switch', express.json(), (req, res) => { + try { + const result = contextSwitchTelemetry.track(req.body || {}); + if (!result.ok) return res.status(400).json(result); + res.json(result); + } catch (error) { + res.status(500).json({ error: 'Failed to record context switch' }); + } +}); + +app.get('/api/process/telemetry/context-switches', (req, res) => { + try { + const hours = req.query.hours ? Number(req.query.hours) : 24; + res.json(contextSwitchTelemetry.getSummary({ hours })); + } catch (error) { + logger.error('Failed to summarize context switches', { error: error.message }); + res.status(500).json({ error: 'Failed to summarize context switches' }); + } +}); + app.get('/api/process/telemetry/details', async (req, res) => { try { const lookbackHours = req.query.lookbackHours ? Number(req.query.lookbackHours) : undefined; @@ -6271,6 +6352,100 @@ app.get('/api/process/task-records/:id', (req, res) => { } }); +// Evidence: collected proof (tests/app-run/reviews/media/data) per task. +app.post('/api/process/evidence/:id/refresh', express.json(), async (req, res) => { + try { + const result = await evidenceService.refresh(req.params.id, { + worktreePath: req.body?.worktreePath + }); + res.json(result); + } catch (error) { + logger.error('Failed to refresh evidence', { id: req.params.id, error: error.message }); + res.status(500).json({ error: error.message || 'Failed to refresh evidence' }); + } +}); + +app.put('/api/process/evidence/:id', express.json(), async (req, res) => { + try { + const body = req.body || {}; + const evidence = Object.prototype.hasOwnProperty.call(body, 'evidence') ? body.evidence : body; + const result = await evidenceService.setDirect(req.params.id, evidence); + res.json({ id: req.params.id, evidence: result }); + } catch (error) { + logger.error('Failed to set evidence', { id: req.params.id, error: error.message }); + res.status(500).json({ error: error.message || 'Failed to set evidence' }); + } +}); + +// Review workflows: data-driven multi-agent review chains per task. +app.get('/api/process/review-workflows', (req, res) => { + try { + const cfg = reviewWorkflowService.getConfig({ force: req.query.force === '1' }); + res.json({ + workflows: Object.entries(cfg.workflows || {}).map(([id, w]) => ({ + id, + label: w.label || id, + description: w.description || '', + stages: (w.stages || []).map(s => ({ role: s.role, agentId: s.agentId, model: s.model || null, effort: s.effort || null })) + })), + riskDefaults: cfg.riskDefaults || {}, + roles: Object.fromEntries(Object.entries(cfg.roles || {}).map(([id, r]) => [id, { label: r.label || id }])) + }); + } catch (error) { + logger.error('Failed to read review workflow config', { error: error.message }); + res.status(500).json({ error: 'Failed to read review workflow config' }); + } +}); + +app.post('/api/process/review-workflows/:id/start', express.json(), async (req, res) => { + try { + const run = await reviewWorkflowService.startWorkflow(req.params.id, req.body?.workflowId, { + standards: Array.isArray(req.body?.standards) ? req.body.standards : [] + }); + res.json({ id: req.params.id, run }); + } catch (error) { + logger.error('Failed to start review workflow', { id: req.params.id, error: error.message }); + res.status(400).json({ error: error.message || 'Failed to start review workflow' }); + } +}); + +app.post('/api/process/review-workflows/:id/advance', express.json(), async (req, res) => { + try { + const run = await reviewWorkflowService.advanceWorkflow(req.params.id); + if (!run) return res.status(404).json({ error: 'No workflow run for this task' }); + res.json({ id: req.params.id, run }); + } catch (error) { + res.status(500).json({ error: error.message || 'Failed to advance review workflow' }); + } +}); + +app.post('/api/process/review-workflows/:id/cancel', express.json(), async (req, res) => { + try { + const run = await reviewWorkflowService.cancelWorkflow(req.params.id); + if (!run) return res.status(404).json({ error: 'No workflow run for this task' }); + res.json({ id: req.params.id, run }); + } catch (error) { + res.status(500).json({ error: error.message || 'Failed to cancel review workflow' }); + } +}); + +app.get('/api/process/evidence/:id/media/:idx', (req, res) => { + try { + const resolved = evidenceService.resolveMediaPath(req.params.id, req.params.idx); + if (resolved.error) return res.status(resolved.status || 400).json({ error: resolved.error }); + res.setHeader('X-Content-Type-Options', 'nosniff'); + // SVG can carry scripts that would run in the orchestrator's own origin on + // direct navigation; force download for it (embedding via is unaffected). + if (resolved.path.toLowerCase().endsWith('.svg')) { + res.setHeader('Content-Disposition', 'attachment'); + } + res.sendFile(resolved.path); + } catch (error) { + logger.error('Failed to serve evidence media', { id: req.params.id, error: error.message }); + res.status(500).json({ error: 'Failed to serve evidence media' }); + } +}); + app.put('/api/process/task-records/:id', express.json(), async (req, res) => { try { const id = req.params.id; @@ -7768,10 +7943,40 @@ app.post('/api/pager/jobs/:id/stop', express.json(), (req, res) => { // Commander Service API (Claude Code Terminal) // ============================================ +// List commander instances (primary + any additional) +app.get('/api/commander/list', (req, res) => { + try { + res.json({ commanders: commanderManager.list() }); + } catch (error) { + logger.error('Failed to list commanders', { error: error.message }); + res.status(500).json({ error: 'Failed to list commanders' }); + } +}); + +// Spawn an additional commander instance +app.post('/api/commander/spawn', express.json(), (req, res) => { + try { + const instance = commanderManager.spawn(req.body?.id); + res.json({ id: instance.id, commanders: commanderManager.list() }); + } catch (error) { + res.status(400).json({ error: error.message || 'Failed to spawn commander' }); + } +}); + +// Remove an additional commander instance (primary cannot be removed) +app.post('/api/commander/remove', express.json(), async (req, res) => { + try { + const result = await commanderManager.remove(req.body?.id); + res.json({ ...result, commanders: commanderManager.list() }); + } catch (error) { + res.status(400).json({ error: error.message || 'Failed to remove commander' }); + } +}); + // Get Commander status app.get('/api/commander/status', (req, res) => { try { - const status = commanderService.getStatus(); + const status = commanderFor(req).getStatus(); res.json(status); } catch (error) { logger.error('Failed to get commander status', { error: error.message }); @@ -7782,7 +7987,7 @@ app.get('/api/commander/status', (req, res) => { // Start Commander terminal app.post('/api/commander/start', async (req, res) => { try { - const result = await commanderService.start(); + const result = await commanderFor(req).start(); res.json(result); } catch (error) { logger.error('Failed to start commander', { error: error.message }); @@ -7795,7 +8000,7 @@ app.post('/api/commander/start-claude', async (req, res) => { try { const { mode, yolo } = req.body; // yolo defaults to true for Commander (YOLO mode enabled by default) - const result = await commanderService.startClaude(mode || 'fresh', yolo !== false); + const result = await commanderFor(req).startClaude(mode || 'fresh', yolo !== false); res.json(result); } catch (error) { logger.error('Failed to start Claude in commander', { error: error.message }); @@ -7811,7 +8016,7 @@ app.post('/api/commander/input', (req, res) => { return res.status(400).json({ error: 'Input is required' }); } - const success = commanderService.sendInput(input); + const success = commanderFor(req).sendInput(input); res.json({ success }); } catch (error) { logger.error('Commander input failed', { error: error.message }); @@ -7835,7 +8040,7 @@ app.post('/api/commander/resize', (req, res) => { }); } - const success = commanderService.resize(cols, rows); + const success = commanderFor(req).resize(cols, rows); res.json({ success, cols, rows }); } catch (error) { logger.error('Commander resize failed', { error: error.message }); @@ -7846,7 +8051,7 @@ app.post('/api/commander/resize', (req, res) => { // Stop Commander terminal app.post('/api/commander/stop', (req, res) => { try { - const result = commanderService.stop(); + const result = commanderFor(req).stop(); res.json(result); } catch (error) { logger.error('Failed to stop commander', { error: error.message }); @@ -7857,7 +8062,7 @@ app.post('/api/commander/stop', (req, res) => { // Restart Commander terminal app.post('/api/commander/restart', async (req, res) => { try { - const result = await commanderService.restart(); + const result = await commanderFor(req).restart(); res.json(result); } catch (error) { logger.error('Failed to restart commander', { error: error.message }); @@ -7869,7 +8074,7 @@ app.post('/api/commander/restart', async (req, res) => { app.get('/api/commander/output', (req, res) => { try { const { lines } = req.query; - const output = commanderService.getRecentOutput(lines ? parseInt(lines) : 50); + const output = commanderFor(req).getRecentOutput(lines ? parseInt(lines) : 50); res.json({ output }); } catch (error) { logger.error('Failed to get commander output', { error: error.message }); @@ -7880,7 +8085,7 @@ app.get('/api/commander/output', (req, res) => { // Clear Commander buffer app.post('/api/commander/clear', (req, res) => { try { - commanderService.clearBuffer(); + commanderFor(req).clearBuffer(); res.json({ success: true }); } catch (error) { logger.error('Failed to clear commander buffer', { error: error.message }); diff --git a/server/pluginLoaderService.js b/server/pluginLoaderService.js index 2618c3b1..27d2006a 100644 --- a/server/pluginLoaderService.js +++ b/server/pluginLoaderService.js @@ -14,7 +14,7 @@ class PluginLoaderService { this.lastLoadedAt = null; this.supportedManifestVersions = new Set([1]); this.allowedCommandSurfaces = new Set(['commander', 'voice', 'ui', 'scheduler']); - this.allowedClientActionTypes = new Set(['open_url', 'open_route', 'copy_text', 'commander_action']); + this.allowedClientActionTypes = new Set(['open_url', 'open_route', 'copy_text', 'commander_action', 'post_route']); this.orchestratorVersion = this.loadOrchestratorVersion(); } @@ -134,6 +134,9 @@ class PluginLoaderService { } const router = express.Router(); + // Plugin routes get JSON bodies parsed for them (the host app uses + // per-route parsers, so plugins would otherwise see req.body undefined). + router.use(express.json({ limit: '1mb' })); const routeBase = `/api/plugins/${encodeURIComponent(id)}`; const commandPrefix = `${id}-`; const capabilities = manifest?.capabilities || {}; @@ -285,7 +288,7 @@ class PluginLoaderService { return out; } - normalizeClientSlots(input) { + normalizeClientSlots(input, pluginId = '') { if (!Array.isArray(input)) return []; const out = []; const seenIds = new Set(); @@ -331,6 +334,29 @@ class PluginLoaderService { normalizedAction.payload = action.payload; } } + if (type === 'post_route') { + // POST to a local route, optionally prompting the user for one input + // field first (e.g. paste a URL). Restricted to the plugin's OWN route + // namespace so a manifest can't POST prompted user input to arbitrary + // app endpoints, and "//host" scheme-relative URLs (which the browser + // treats as cross-origin) are rejected. + const route = String(action.route || '').trim(); + const ownPrefix = pluginId ? `/api/plugins/${pluginId}/` : '/api/plugins/'; + if (!route.startsWith('/') || route.startsWith('//') || !route.startsWith(ownPrefix)) { + throw new Error(`post_route action route must start with ${ownPrefix} for slot id: ${id}`); + } + normalizedAction.route = route; + const promptLabel = String(action.prompt || '').trim(); + if (promptLabel) { + normalizedAction.prompt = promptLabel.slice(0, 200); + const field = String(action.field || 'value').trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,39}$/.test(field)) throw new Error(`Invalid post_route field name for slot id: ${id}`); + normalizedAction.field = field; + } + if (action.payload && typeof action.payload === 'object' && !Array.isArray(action.payload)) { + normalizedAction.payload = action.payload; + } + } const order = Number(item.order); out.push({ @@ -408,7 +434,7 @@ class PluginLoaderService { throw new Error('Manifest client must be an object'); } normalized.client = { - slots: this.normalizeClientSlots(client.slots || []) + slots: this.normalizeClientSlots(client.slots || [], normalized.id) }; } diff --git a/server/prReviewAutomationService.js b/server/prReviewAutomationService.js index 4045d18a..fc5a0b43 100644 --- a/server/prReviewAutomationService.js +++ b/server/prReviewAutomationService.js @@ -3,6 +3,8 @@ const path = require('path'); const winston = require('winston'); +const { findAvailableWorktree, spawnAgentInSession } = require('./agentSpawnHelper'); + const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.json()), @@ -13,6 +15,11 @@ const logger = winston.createLogger({ const CONFIG_PATH = 'global.ui.tasks.automations.prReview'; +// Anthropic prompt caches go cold after ~1h idle. Past this age, sending +// feedback text into the ORIGINAL session wastes the reprompt on a cold +// cache — a fresh session seeded with handoff notes is cheaper and cleaner. +const PROMPT_CACHE_TTL_MS = 55 * 60 * 1000; + const DEFAULT_CONFIG = { enabled: false, pollEnabled: true, @@ -440,22 +447,23 @@ class PrReviewAutomationService { } // Find an available worktree in the active workspace - const worktreeId = await this._findAvailableWorktree(pr, cfg); - if (!worktreeId) { + const target = await this._findAvailableWorktree(pr, cfg); + if (!target) { logger.warn('No available worktree for reviewer', { prId: pr.prId }); return false; } - const sessionId = `${pr.repo || 'review'}-${worktreeId}-claude`; + const { worktreeId, repoName } = target; + const sessionId = `${repoName || pr.repo || 'review'}-${worktreeId}-claude`; const prompt = this._buildReviewPrompt(pr, cfg); try { - // Start the agent const agent = cfg.reviewerAgent || 'claude'; - const started = this.sessionManager.startAgentWithConfig(sessionId, { - provider: agent, - skipPermissions: true, - mode: 'fresh' + const started = spawnAgentInSession({ + sessionManager: this.sessionManager, + sessionId, + agentId: agent, + prompt }); if (!started) { @@ -463,12 +471,6 @@ class PrReviewAutomationService { return false; } - // Wait for agent init, then send prompt - const initDelay = agent === 'codex' ? 15_000 : 8_000; - setTimeout(() => { - this.sessionManager.writeToSession(sessionId, prompt + '\n'); - }, initDelay); - // Track the active reviewer this.activeReviewers.set(pr.prId, { worktreeId, @@ -497,40 +499,20 @@ class PrReviewAutomationService { // --------------------------------------------------------------------------- async _findAvailableWorktree(pr, cfg) { - if (!this.workspaceManager) return null; - - // Get active workspace - const activeWs = this.workspaceManager.getActiveWorkspace?.(); - const wsId = activeWs?.id; - if (!wsId) { - logger.warn('No active workspace found for reviewer spawn'); - return null; - } - - const workspace = this.workspaceManager.getWorkspaceById?.(wsId); - if (!workspace) return null; - - // Find worktrees that aren't currently used by active reviewers - const usedWorktrees = new Set( + const usedWorktreeIds = new Set( Array.from(this.activeReviewers.values()).map(r => r.worktreeId) ); - const terminals = workspace.terminals || []; - for (const terminal of terminals) { - const wId = terminal.worktreeId || terminal.worktree; - if (!wId) continue; - if (usedWorktrees.has(wId)) continue; - - // Check if the session in this worktree is idle/exited - const repoName = terminal.repository?.name || terminal.repositoryName || ''; - const claudeSessionId = `${repoName}-${wId}-claude`; - const session = this.sessionManager?.getSessionById?.(claudeSessionId); - if (!session || session.status === 'exited' || session.status === 'idle') { - return wId; - } - } + const target = findAvailableWorktree({ + workspaceManager: this.workspaceManager, + sessionManager: this.sessionManager, + usedWorktreeIds + }); - return null; + if (!target) { + logger.warn('No available worktree found for reviewer spawn'); + } + return target; } // --------------------------------------------------------------------------- @@ -590,6 +572,14 @@ class PrReviewAutomationService { } } + // Cache is "cold" only when we have a prompt timestamp older than the TTL. + // A MISSING timestamp is unknown, not cold: if a live author session + // exists we deliver there rather than needlessly spawning a fresh fixer. + const promptSentMs = Date.parse(record.promptSentAt || '') || 0; + const cacheCold = promptSentMs + ? (Date.now() - promptSentMs) > PROMPT_CACHE_TTL_MS + : !targetSession; + const feedbackMsg = [ `\n--- PR Review Feedback ---`, `PR #${reviewInfo.number} has been reviewed by ${reviewInfo.reviewUser || 'AI reviewer'}.`, @@ -601,24 +591,80 @@ class PrReviewAutomationService { `--- End Review Feedback ---\n` ].join('\n'); - if (targetSession) { - logger.info('Sending review feedback to session', { prId, sessionId: targetSession }); + if (targetSession && !cacheCold) { + logger.info('Sending review feedback to warm session', { prId, sessionId: targetSession }); this.sessionManager.writeToSession(targetSession, feedbackMsg); return; } - // If no active session found and autoSpawnFixer is on, spawn a fixer + // Session is missing or its prompt cache has gone cold (>~1h): a fresh + // fixer seeded with the review feedback + handoff notes beats continuing + // a cold conversation. if (cfg.autoSpawnFixer) { - logger.info('Original session not found, would spawn fixer', { prId }); - this.taskRecordService?.upsert?.(prId, { - notes: `Review feedback pending - original session not found. Fixer needed.` - }); - } else { - logger.info('No active session found for feedback, storing in task record', { prId }); + const spawned = await this._spawnFreshFixer(prId, reviewInfo, record); + if (spawned) return; + } + + if (targetSession) { + // Fixer disabled/unavailable — still deliver into the old session. + logger.info('Sending review feedback to stale session (cache likely cold)', { prId, sessionId: targetSession }); + this.sessionManager.writeToSession(targetSession, feedbackMsg); + return; + } + + logger.info('No delivery target for feedback, storing in task record', { prId }); + this.taskRecordService?.upsert?.(prId, { + notes: `Review: changes requested by ${reviewInfo.reviewUser || 'AI'}. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 500)}` + }); + } + + // --------------------------------------------------------------------------- + // Internal: spawn a fresh fixer agent (fresh window = warm start, no cold cache) + // --------------------------------------------------------------------------- + + async _spawnFreshFixer(prId, reviewInfo, record) { + const match = prId.match(/^pr:([^/]+)\/([^#]+)#(\d+)$/); + if (!match) return false; + const [, owner, repo, numStr] = match; + const number = parseInt(numStr, 10); + + const target = await this._findAvailableWorktree({ prId }, this.getConfig()); + if (!target) { this.taskRecordService?.upsert?.(prId, { - notes: `Review: changes requested by ${reviewInfo.reviewUser || 'AI'}. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 500)}` + notes: `Review feedback pending — no free worktree to spawn a fixer. Feedback: ${(reviewInfo.reviewBody || '').slice(0, 400)}` }); + return false; } + + const handoffNotes = String(record?.evidence?.handoff?.notes || '').trim(); + const prompt = [ + `You are fixing PR #${number} in ${owner}/${repo} after review feedback. This is a FRESH session — everything you need is below.`, + '', + handoffNotes ? `Handoff notes from the implementing agent:\n${handoffNotes}\n` : '', + 'Review feedback to address:', + reviewInfo.reviewBody || '(see the PR review on GitHub)', + '', + `Steps: \`gh pr checkout ${number}\` in this worktree, address EVERY point above, run the tests, commit and push to the SAME branch (never create a new one), then reply on the PR describing the fixes and update the agent-evidence block.`, + `Use \`gh pr view ${number} --comments\` for full context.` + ].filter(Boolean).join('\n'); + + const sessionId = `${target.repoName || repo}-${target.worktreeId}-claude`; + const started = spawnAgentInSession({ + sessionManager: this.sessionManager, + sessionId, + agentId: this.getConfig().reviewerAgent || 'claude', + prompt + }); + + if (!started) return false; + + this.taskRecordService?.upsert?.(prId, { + fixerSpawnedAt: new Date().toISOString(), + fixerWorktreeId: target.worktreeId + }); + logger.info('Fresh fixer spawned for review feedback', { prId, sessionId, worktreeId: target.worktreeId }); + this._emitUpdate('fixer-spawned', { prId, sessionId, worktreeId: target.worktreeId }); + return true; } // --------------------------------------------------------------------------- diff --git a/server/reviewWorkflowService.js b/server/reviewWorkflowService.js new file mode 100644 index 00000000..2df604e3 --- /dev/null +++ b/server/reviewWorkflowService.js @@ -0,0 +1,489 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const winston = require('winston'); + +const { findAvailableWorktree, spawnAgentInSession } = require('./agentSpawnHelper'); +const { getAgentWorkspaceDir } = require('./utils/pathUtils'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), + transports: [ + new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'review-workflows.log'), maxsize: 2_000_000, maxFiles: 2 }) + ] +}); + +const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'review-workflows.json'); +const USER_CONFIG_PATH = path.join(getAgentWorkspaceDir(), 'review-workflows.json'); +const POLL_MS = 30_000; +const PR_ID_RE = /^pr:([^/]+)\/([^#]+)#(\d+)$/; + +const deepMerge = (base, override) => { + if (!override || typeof override !== 'object' || Array.isArray(override)) return override ?? base; + if (!base || typeof base !== 'object' || Array.isArray(base)) return override; + const out = { ...base }; + for (const [k, v] of Object.entries(override)) { + out[k] = deepMerge(base[k], v); + } + return out; +}; + +class ReviewWorkflowService { + constructor(deps = {}) { + this.taskRecordService = deps.taskRecordService || null; + this.pullRequestService = deps.pullRequestService || null; + this.sessionManager = deps.sessionManager || null; + this.workspaceManager = deps.workspaceManager || null; + this.evidenceService = deps.evidenceService || null; + this.io = deps.io || null; + this.configPath = deps.configPath || DEFAULT_CONFIG_PATH; + this.userConfigPath = deps.userConfigPath || USER_CONFIG_PATH; + + this.pollTimer = null; + this._configCache = null; + this._configCacheAt = 0; + this._pendingSpawns = new Map(); // taskId -> cancelPendingPrompt fn for the in-flight stage spawn + } + + static getInstance(deps = {}) { + if (!ReviewWorkflowService.instance) { + ReviewWorkflowService.instance = new ReviewWorkflowService(deps); + } + return ReviewWorkflowService.instance; + } + + // --------------------------------------------------------------------------- + // Config + // --------------------------------------------------------------------------- + + getConfig({ force = false } = {}) { + const now = Date.now(); + if (!force && this._configCache && now - this._configCacheAt < 10_000) { + return this._configCache; + } + + let base = { roles: {}, workflows: {}, riskDefaults: {}, stageTimeoutMinutes: 45 }; + try { + base = deepMerge(base, JSON.parse(fs.readFileSync(this.configPath, 'utf8'))); + } catch (e) { + logger.error('Failed to read review workflow config', { path: this.configPath, error: e.message }); + } + + try { + if (fs.existsSync(this.userConfigPath)) { + base = deepMerge(base, JSON.parse(fs.readFileSync(this.userConfigPath, 'utf8'))); + } + } catch (e) { + logger.warn('Failed to merge user review workflow config', { path: this.userConfigPath, error: e.message }); + } + + this._configCache = base; + this._configCacheAt = now; + return base; + } + + getWorkflowForRisk(risk) { + const cfg = this.getConfig(); + const id = cfg.riskDefaults?.[String(risk || '').toLowerCase()] || 'standard'; + return cfg.workflows?.[id] ? id : Object.keys(cfg.workflows || {})[0] || null; + } + + // --------------------------------------------------------------------------- + // Run lifecycle + // --------------------------------------------------------------------------- + + getRun(taskId) { + return this.taskRecordService?.get?.(taskId)?.reviewWorkflow || null; + } + + async startWorkflow(taskId, workflowId, { standards = [] } = {}) { + if (!PR_ID_RE.test(String(taskId || ''))) { + throw new Error('Review workflows currently support pr:* tasks only'); + } + + const cfg = this.getConfig(); + const workflow = cfg.workflows?.[workflowId]; + if (!workflow || !Array.isArray(workflow.stages) || !workflow.stages.length) { + throw new Error(`Unknown workflow: ${workflowId}`); + } + + const existing = this.getRun(taskId); + if (existing && (existing.status === 'running' || existing.status === 'pending')) { + throw new Error('A review workflow is already running for this task'); + } + + const run = { + workflowId, + status: 'running', + stageIndex: 0, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + stages: workflow.stages.map((s) => ({ + role: s.role, + agentId: s.agentId || 'claude', + model: s.model || null, + effort: s.effort || null, + status: 'pending' + })) + }; + + await this.taskRecordService.upsert(taskId, { reviewWorkflow: run }); + const spawned = await this._spawnStage(taskId, 0, { standards }); + if (!spawned) { + await this._patchRun(taskId, { status: 'stalled' }); + this._emit('stage-spawn-failed', { taskId, stageIndex: 0 }); + return this.getRun(taskId); + } + + this.startPolling(); + this._emit('workflow-started', { taskId, workflowId }); + return this.getRun(taskId); + } + + async cancelWorkflow(taskId) { + const run = this.getRun(taskId); + if (!run) return null; + // Stop an in-flight stage spawn's delayed prompt injection — without this, + // cancelling inside the agent-init window still types the review prompt + // into the terminal seconds after the run was reported cancelled. + const cancelSpawn = this._pendingSpawns.get(taskId); + if (cancelSpawn) { + try { cancelSpawn(); } catch { /* timers may already have fired */ } + this._pendingSpawns.delete(taskId); + } + await this._patchRun(taskId, { status: 'cancelled', completedAt: new Date().toISOString() }); + this._emit('workflow-cancelled', { taskId }); + return this.getRun(taskId); + } + + // Force-advance past a stalled/failed stage. + async advanceWorkflow(taskId) { + const run = this.getRun(taskId); + if (!run || !Array.isArray(run.stages)) return null; + // Only live runs can be advanced — advancing spawns a real agent, so a + // cancelled or completed run must never be resurrected through this path. + if (!['running', 'blocked_fix', 'stalled'].includes(String(run.status))) { + return run; + } + const idx = Number(run.stageIndex) || 0; + const stages = run.stages.map((s, i) => (i === idx && s.status !== 'done') + ? { ...s, status: 'skipped', completedAt: new Date().toISOString() } + : s); + await this._patchRun(taskId, { stages }); + await this._proceedFrom(taskId, idx); + return this.getRun(taskId); + } + + // --------------------------------------------------------------------------- + // Stage mechanics + // --------------------------------------------------------------------------- + + async _patchRun(taskId, patch) { + const run = this.getRun(taskId) || {}; + const next = { ...run, ...patch, updatedAt: new Date().toISOString() }; + await this.taskRecordService.upsert(taskId, { reviewWorkflow: next }); + return next; + } + + async _spawnStage(taskId, stageIndex, { standards = [] } = {}) { + const run = this.getRun(taskId); + const stage = run?.stages?.[stageIndex]; + if (!stage) return false; + + const match = String(taskId).match(PR_ID_RE); + if (!match) return false; + const [, owner, repo, numStr] = match; + const number = parseInt(numStr, 10); + + const target = findAvailableWorktree({ + workspaceManager: this.workspaceManager, + sessionManager: this.sessionManager, + usedWorktreeIds: new Set( + (run.stages || []) + .filter((s, i) => i !== stageIndex && s.status === 'running' && s.worktreeId) + .map(s => s.worktreeId) + ) + }); + if (!target) { + logger.warn('No available worktree for workflow stage', { taskId, stageIndex }); + return false; + } + + const sessionId = `${target.repoName || repo}-${target.worktreeId}-claude`; + const record = this.taskRecordService?.get?.(taskId) || {}; + const prompt = this._buildStagePrompt({ + owner, + repo, + number, + title: record.title || `PR #${number}`, + stage, + stageIndex, + stageCount: run.stages.length, + priorStages: run.stages.slice(0, stageIndex), + standards + }); + + const spawned = spawnAgentInSession({ + sessionManager: this.sessionManager, + sessionId, + agentId: stage.agentId || 'claude', + model: stage.model || null, + effort: stage.effort || null, + prompt + }); + if (!spawned) return false; + if (typeof spawned.cancelPendingPrompt === 'function') { + this._pendingSpawns.set(taskId, spawned.cancelPendingPrompt); + } + + const stages = run.stages.map((s, i) => i === stageIndex + ? { ...s, status: 'running', sessionId, worktreeId: target.worktreeId, spawnedAt: new Date().toISOString() } + : s); + await this._patchRun(taskId, { stages, stageIndex, status: 'running' }); + this._emit('stage-spawned', { taskId, stageIndex, role: stage.role, sessionId }); + return true; + } + + _buildStagePrompt({ owner, repo, number, title, stage, stageIndex, stageCount, priorStages, standards }) { + const cfg = this.getConfig(); + const role = cfg.roles?.[stage.role] || {}; + const focusBullets = Array.isArray(role.focusBullets) && role.focusBullets.length + ? role.focusBullets + : ['Correctness', 'Security', 'Tests', 'Conventions']; + + const priorSummary = (priorStages || []) + .filter(s => s.verdict) + .map(s => `- ${s.role}: ${s.verdict}`) + .join('\n'); + + const standardsList = (standards && standards.length ? standards : ['CLAUDE.md', 'CODEBASE_DOCUMENTATION.md']) + .map(s => `- ${s}`) + .join('\n'); + + return [ + `You are the ${role.label || stage.role} in stage ${stageIndex + 1}/${stageCount} of an agent review chain for PR #${number} in ${owner}/${repo}.`, + title ? `PR title: ${title}` : '', + '', + 'Do NOT create branches or modify any files. This is a READ-ONLY review.', + '', + `Your review focus (${stage.role}):`, + ...focusBullets.map(b => `- ${b}`), + '', + 'Standards to review against (read them first):', + standardsList, + '', + priorSummary ? `Earlier stages in this chain concluded:\n${priorSummary}\nRead their comments with \`gh pr view ${number} --comments\` and do not repeat confirmed findings — verify the fixes instead.` : '', + '', + `Use \`gh pr diff ${number}\` and \`gh pr view ${number}\` to inspect the change. Check out and run tests if the repo supports it.`, + '', + 'When done you MUST do BOTH of the following:', + `1. Post your structured result as a PR comment containing a fenced agent-evidence block (see docs/agents/EVIDENCE_PROTOCOL.md in the orchestrator repo). Format:`, + '```', + `gh pr comment ${number} --body '`, + '', + '```agent-evidence', + JSON.stringify({ + reviews: [{ + role: stage.role, + agentId: stage.agentId || 'claude', + model: stage.model || undefined, + verdict: 'approved | needs_fix | commented', + summary: 'one-paragraph outcome', + findings: 0, + fixed: 0 + }] + }, null, 2), + '```', + "'", + '```', + `2. Submit the matching GitHub review verdict:`, + ` - \`gh pr review ${number} --approve -b "..."\` if it passes your review`, + ` - \`gh pr review ${number} --request-changes -b "specific, actionable feedback"\` if it must change`, + ` - \`gh pr review ${number} --comment -b "..."\` for non-blocking notes`, + '', + 'Be thorough but concise. Meaningful issues only, no style nitpicks.' + ].filter(Boolean).join('\n'); + } + + // --------------------------------------------------------------------------- + // Progress detection (polls GitHub reviews for the running stage) + // --------------------------------------------------------------------------- + + startPolling() { + if (this.pollTimer) return; + this.pollTimer = setInterval(() => { + this.pollActiveRuns().catch(e => logger.error('Workflow poll failed', { error: e.message })); + }, POLL_MS); + if (typeof this.pollTimer?.unref === 'function') this.pollTimer.unref(); + } + + stopPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + listActiveRuns() { + const records = this.taskRecordService?.list?.() || []; + return records + .filter(r => r?.reviewWorkflow && (r.reviewWorkflow.status === 'running')) + .map(r => ({ taskId: r.id, run: r.reviewWorkflow })); + } + + async pollActiveRuns() { + // Serialize poll cycles: a slow GitHub round-trip must not let a second + // interval tick start and act on the same stale run snapshot (which could + // spawn the next reviewer twice). + if (this._polling) return { skipped: true, reason: 'in-progress' }; + this._polling = true; + try { + const active = this.listActiveRuns(); + if (!active.length) { + this.stopPolling(); + return { checked: 0 }; + } + + let progressed = 0; + for (const { taskId } of active) { + try { + const moved = await this._checkRun(taskId); + if (moved) progressed++; + } catch (e) { + logger.warn('Failed to check workflow run', { taskId, error: e.message }); + } + } + return { checked: active.length, progressed }; + } finally { + this._polling = false; + } + } + + async _checkRun(taskId) { + // Re-read the run fresh (not a snapshot from listActiveRuns): the state + // may have changed since the poll cycle began. + const run = this.getRun(taskId); + if (!run || run.status !== 'running') return false; + const idx = Number(run.stageIndex) || 0; + const stage = run.stages?.[idx]; + if (!stage || stage.status !== 'running') return false; + + const match = String(taskId).match(PR_ID_RE); + if (!match) return false; + const [, owner, repo, numStr] = match; + const number = parseInt(numStr, 10); + + // Stage timeout → stall the run for human attention. + const cfg = this.getConfig(); + const timeoutMs = Math.max(5, Number(cfg.stageTimeoutMinutes) || 45) * 60_000; + const spawnedMs = Date.parse(stage.spawnedAt || '') || 0; + const timedOut = spawnedMs && Date.now() - spawnedMs > timeoutMs; + + let latestReview = null; + try { + const prData = await this.pullRequestService.getPullRequest({ owner, repo, number, fields: ['reviews'] }); + const reviews = prData?.reviews || []; + const candidates = reviews + .filter(r => r.state && r.state !== 'PENDING' && r.state !== 'DISMISSED') + .filter(r => { + const submitted = Date.parse(r.submittedAt || '') || 0; + return submitted && spawnedMs && submitted >= spawnedMs; + }) + .sort((a, b) => new Date(b.submittedAt || 0) - new Date(a.submittedAt || 0)); + // Prefer a review the stage agent actually authored (it embeds an + // agent-evidence block / names its role), so a stray human or unrelated + // bot review submitted during the window can't be misattributed to the + // stage. Fall back to the latest only if no marked review is found. + const marker = new RegExp(`agent-evidence|"role"\\s*:\\s*"${stage.role}"|\\b${stage.role}\\b`, 'i'); + latestReview = candidates.find(r => marker.test(String(r.body || ''))) || candidates[0] || null; + } catch (e) { + logger.warn('Failed to fetch PR reviews for workflow', { taskId, error: e.message }); + } + + // CAS guard: the run/stage state must not have advanced while we were + // awaiting GitHub. If it did, abandon this check — a later poll re-reads. + const fresh = this.getRun(taskId); + if (!fresh || fresh.status !== 'running' || Number(fresh.stageIndex) !== idx + || fresh.stages?.[idx]?.status !== 'running' + || fresh.stages?.[idx]?.spawnedAt !== stage.spawnedAt) { + return false; + } + + if (!latestReview) { + if (timedOut) { + const stages = run.stages.map((s, i) => i === idx ? { ...s, status: 'failed' } : s); + await this._patchRun(taskId, { stages, status: 'stalled' }); + this._emit('stage-timeout', { taskId, stageIndex: idx, role: stage.role }); + return true; + } + return false; + } + + const state = String(latestReview.state || '').toLowerCase(); + const verdict = state === 'approved' ? 'approved' + : state === 'changes_requested' ? 'needs_fix' + : 'commented'; + + const stages = run.stages.map((s, i) => i === idx + ? { ...s, status: 'done', verdict, completedAt: latestReview.submittedAt || new Date().toISOString() } + : s); + await this._patchRun(taskId, { stages }); + + // Record the stage outcome into the evidence review chain. + try { + await this.evidenceService?.setDirect?.(taskId, { + reviews: [{ + role: stage.role, + agentId: stage.agentId, + model: stage.model || undefined, + effort: stage.effort || undefined, + verdict, + summary: String(latestReview.body || '').slice(0, 2000) || undefined, + at: latestReview.submittedAt || new Date().toISOString(), + by: latestReview.author?.login || stage.sessionId || undefined + }] + }); + // Pull in any agent-evidence comment blocks the reviewer posted. + await this.evidenceService?.refresh?.(taskId); + } catch (e) { + logger.warn('Failed to record stage evidence', { taskId, error: e.message }); + } + + this._emit('stage-completed', { taskId, stageIndex: idx, role: stage.role, verdict }); + + if (verdict === 'needs_fix') { + await this._patchRun(taskId, { status: 'blocked_fix' }); + this._emit('workflow-blocked', { taskId, stageIndex: idx, role: stage.role }); + return true; + } + + await this._proceedFrom(taskId, idx); + return true; + } + + async _proceedFrom(taskId, completedIndex) { + const run = this.getRun(taskId); + if (!run) return; + const nextIndex = completedIndex + 1; + if (nextIndex >= (run.stages?.length || 0)) { + await this._patchRun(taskId, { status: 'complete', completedAt: new Date().toISOString() }); + this._emit('workflow-complete', { taskId, workflowId: run.workflowId }); + return; + } + const spawned = await this._spawnStage(taskId, nextIndex, {}); + if (!spawned) { + await this._patchRun(taskId, { status: 'stalled', stageIndex: nextIndex }); + this._emit('stage-spawn-failed', { taskId, stageIndex: nextIndex }); + } + } + + _emit(event, data) { + if (this.io) { + this.io.emit('review-workflow', { event, ...data, at: new Date().toISOString() }); + } + } +} + +module.exports = { ReviewWorkflowService }; diff --git a/server/serverLaunchCommandResolver.js b/server/serverLaunchCommandResolver.js new file mode 100644 index 00000000..fb302608 --- /dev/null +++ b/server/serverLaunchCommandResolver.js @@ -0,0 +1,114 @@ +'use strict'; + +const { isSafeFlag, hasDangerousShell } = require('./utils/shellSafety'); + +// Resolves the dev-server launch command for a session from the cascaded +// config instead of a hardcoded binary. Config keys (any cascade level: +// Global → Category → Framework → Project → Worktree): +// +// "serverCommand": "hytopia start {{gameMode}} {{commonFlags}}" +// "gameModes": { "deathmatch": { "flag": "--mode=deathmatch", "label": "Deathmatch" } } +// "commonFlags": { "unlockAll": { "flag": "--unlock-all", "label": "Unlock All" } } +// +// {{gameMode}} substitutes the selected mode's flag; {{commonFlags}} the +// flags enabled in launchSettings.flags. Templates without placeholders +// just run as-is. + +const DEFAULT_COMMANDS_BY_TYPE = { + 'hytopia-game': 'hytopia start {{gameMode}} {{commonFlags}}', + default: 'npm run dev' +}; + +const findTerminalForSession = (workspaceManager, sessionId) => { + try { + const active = workspaceManager?.getActiveWorkspace?.(); + const workspace = active?.id ? workspaceManager.getWorkspaceById?.(active.id) : null; + const terminals = workspace?.terminals?.pairs || workspace?.terminals || []; + if (!Array.isArray(terminals)) return null; + + const sid = String(sessionId || ''); + const worktreeMatch = sid.match(/-(work\d+)-server$/) || sid.match(/-(work\d+)-/); + const worktreeId = worktreeMatch ? worktreeMatch[1] : null; + const repoName = worktreeId ? sid.slice(0, sid.indexOf(`-${worktreeId}`)) : null; + + return terminals.find((t) => { + const tWorktree = t?.worktreeId || t?.worktree || null; + const tRepo = t?.repository?.name || t?.repositoryName || null; + if (worktreeId && tWorktree && tWorktree !== worktreeId) return false; + if (repoName && tRepo && tRepo !== repoName) return false; + return !!(tWorktree || tRepo); + }) || null; + } catch { + return null; + } +}; + +const resolveServerLaunchCommand = async ({ + workspaceManager, + sessionId, + cwd, + environment, + launchSettings +} = {}) => { + const terminal = findTerminalForSession(workspaceManager, sessionId); + const repositoryType = terminal?.repository?.type + || workspaceManager?.getActiveWorkspace?.()?.type + || null; + + let cascaded = null; + try { + if (repositoryType && typeof workspaceManager?.getCascadedConfigForWorktree === 'function') { + cascaded = await workspaceManager.getCascadedConfigForWorktree(repositoryType, cwd || null); + } + } catch { + cascaded = null; + } + + // The command template + all substituted values come from repo/user config + // (.orchestrator-config.json) and are written to a shell, so a cloned repo + // could carry a malicious flag. Every config-derived value is validated + // against a shell-safe allowlist; unsafe values are dropped. If the template + // itself carries shell metacharacters (beyond its {{...}} placeholders) we + // fall back to the safe built-in default rather than run it. + const rawTemplate = String( + cascaded?.serverCommand + || DEFAULT_COMMANDS_BY_TYPE[repositoryType] + || DEFAULT_COMMANDS_BY_TYPE.default + ); + const templateSansPlaceholders = rawTemplate.replace(/\{\{\s*(gameMode|commonFlags)\s*\}\}/g, ''); + const template = hasDangerousShell(templateSansPlaceholders) + ? (DEFAULT_COMMANDS_BY_TYPE[repositoryType] || DEFAULT_COMMANDS_BY_TYPE.default) + : rawTemplate; + + // {{gameMode}}: the selected environment may be a configured game-mode key. + const gameModes = cascaded?.gameModes && typeof cascaded.gameModes === 'object' ? cascaded.gameModes : {}; + const selectedMode = gameModes[String(environment || '')] || null; + const rawModeFlag = String(selectedMode?.flag || '').trim(); + const gameModeFlag = isSafeFlag(rawModeFlag) ? rawModeFlag : ''; + + // {{commonFlags}}: flags toggled on in launch settings. + const commonFlags = cascaded?.commonFlags && typeof cascaded.commonFlags === 'object' ? cascaded.commonFlags : {}; + const enabled = launchSettings?.flags && typeof launchSettings.flags === 'object' ? launchSettings.flags : {}; + const commonFlagsStr = Object.entries(commonFlags) + .filter(([key]) => enabled[key] === true) + .map(([, def]) => String(def?.flag || '').trim()) + .filter((f) => f && isSafeFlag(f)) + .join(' '); + + let command = template + .replace(/\{\{\s*gameMode\s*\}\}/g, gameModeFlag) + .replace(/\{\{\s*commonFlags\s*\}\}/g, commonFlagsStr) + .replace(/\s{2,}/g, ' ') + .trim(); + + const rawGameArgs = String(launchSettings?.gameArgs || '').trim(); + if (rawGameArgs && isSafeFlag(rawGameArgs)) command += ` ${rawGameArgs}`; + + return { + command, + repositoryType, + usedGameMode: (selectedMode && gameModeFlag) ? String(environment) : null + }; +}; + +module.exports = { resolveServerLaunchCommand, DEFAULT_COMMANDS_BY_TYPE }; diff --git a/server/sessionManager.js b/server/sessionManager.js index cafd2c12..6999eec5 100644 --- a/server/sessionManager.js +++ b/server/sessionManager.js @@ -2940,7 +2940,7 @@ class SessionManager extends EventEmitter { return getShellKind(); } - buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions }) { + buildClaudeCommand({ shellKind, mode, resumeId, skipPermissions, model }) { let cmd = 'claude'; if (mode === 'continue') { @@ -2955,6 +2955,12 @@ class SessionManager extends EventEmitter { cmd += ' --dangerously-skip-permissions'; } + // Model alias or full id (e.g. "sonnet", "opus", "claude-opus-4-8[1m]"). + const modelName = String(model || '').trim(); + if (modelName && /^[A-Za-z0-9._[\]-]+$/.test(modelName)) { + cmd += ` --model ${quoteForShell(modelName, shellKind)}`; + } + return cmd; } @@ -3118,7 +3124,8 @@ class SessionManager extends EventEmitter { shellKind, mode: finalConfig.mode, resumeId: finalConfig.resumeId, - skipPermissions + skipPermissions, + model: finalConfig.model }); const resolvedCommand = this.resolveClaudeCommand(claudeCmd, provider); if (resolvedCommand.warning) { diff --git a/server/taskRecordService.js b/server/taskRecordService.js index 73ab3bea..9cda6e6b 100644 --- a/server/taskRecordService.js +++ b/server/taskRecordService.js @@ -199,6 +199,226 @@ const normalizeReviewChecklist = (raw) => { return Object.keys(out).length ? out : null; }; +const WORKFLOW_RUN_STATUSES = new Set(['pending', 'running', 'blocked_fix', 'stalled', 'complete', 'cancelled']); +const WORKFLOW_STAGE_STATUSES = new Set(['pending', 'running', 'done', 'failed', 'skipped']); + +const normalizeReviewWorkflow = (raw) => { + if (raw === null) return null; + if (!raw || typeof raw !== 'object') return null; + + const out = {}; + const str = (v, max) => { + const s = String(v ?? '').trim(); + return s ? s.slice(0, max) : null; + }; + + const workflowId = str(raw.workflowId, 60); + if (workflowId) out.workflowId = workflowId; + + const status = String(raw.status || '').trim().toLowerCase(); + if (WORKFLOW_RUN_STATUSES.has(status)) out.status = status; + + const stageIndex = Number(raw.stageIndex); + if (Number.isInteger(stageIndex) && stageIndex >= 0 && stageIndex < 20) out.stageIndex = stageIndex; + + for (const key of ['startedAt', 'updatedAt', 'completedAt']) { + const dt = normalizeDateTime(raw[key]); + if (dt) out[key] = dt; + } + + if (Array.isArray(raw.stages)) { + const stages = raw.stages.slice(0, 10).map((s) => { + if (!s || typeof s !== 'object') return null; + const stage = {}; + const role = str(s.role, 60); + if (role) stage.role = role.toLowerCase(); + const agentId = str(s.agentId, 40); + if (agentId) stage.agentId = agentId.toLowerCase(); + const model = str(s.model, 80); + if (model) stage.model = model; + const effort = str(s.effort, 20); + if (effort) stage.effort = effort.toLowerCase(); + const stageStatus = String(s.status || '').trim().toLowerCase(); + if (WORKFLOW_STAGE_STATUSES.has(stageStatus)) stage.status = stageStatus; + const sessionId = str(s.sessionId, 240); + if (sessionId) stage.sessionId = sessionId; + const worktreeId = str(s.worktreeId, 120); + if (worktreeId) stage.worktreeId = worktreeId; + const verdict = normalizeReviewOutcome(s.verdict); + if (verdict) stage.verdict = verdict; + const reviewUrl = str(s.reviewUrl, 600); + if (reviewUrl) stage.reviewUrl = reviewUrl; + for (const key of ['spawnedAt', 'completedAt']) { + const dt = normalizeDateTime(s[key]); + if (dt) stage[key] = dt; + } + return Object.keys(stage).length ? stage : null; + }).filter(Boolean); + if (stages.length) out.stages = stages; + } + + return Object.keys(out).length ? out : null; +}; + +const evidenceString = (v, max) => { + const s = String(v ?? '').trim(); + return s ? s.slice(0, max) : null; +}; + +const evidenceCount = (v) => { + const x = Number(v); + if (!Number.isFinite(x) || x < 0) return null; + return Math.round(x); +}; + +const EVIDENCE_MEDIA_TYPES = new Set(['image', 'video', 'gif', 'other']); + +const normalizeEvidence = (raw) => { + if (raw === null) return null; + if (!raw || typeof raw !== 'object') return null; + + const out = {}; + + const summary = evidenceString(raw.summary, 2000); + if (summary) out.summary = summary; + + if (raw.tests && typeof raw.tests === 'object') { + const t = {}; + if (raw.tests.ran !== undefined) t.ran = !!raw.tests.ran; + const command = evidenceString(raw.tests.command, 300); + if (command) t.command = command; + const passed = evidenceCount(raw.tests.passed); + if (passed !== null) t.passed = passed; + const failed = evidenceCount(raw.tests.failed); + if (failed !== null) t.failed = failed; + const output = evidenceString(raw.tests.output, 4000); + if (output) t.output = output; + const at = normalizeDateTime(raw.tests.at); + if (at) t.at = at; + if (Object.keys(t).length) out.tests = t; + } + + if (raw.appRun && typeof raw.appRun === 'object') { + const a = {}; + if (raw.appRun.ran !== undefined) a.ran = !!raw.appRun.ran; + const method = evidenceString(raw.appRun.method, 80); + if (method) a.method = method; + const url = evidenceString(raw.appRun.url, 600); + if (url) a.url = url; + const notes = evidenceString(raw.appRun.notes, 2000); + if (notes) a.notes = notes; + const at = normalizeDateTime(raw.appRun.at); + if (at) a.at = at; + if (Object.keys(a).length) out.appRun = a; + } + + if (Array.isArray(raw.media)) { + const media = raw.media.slice(0, 20).map((m) => { + if (!m || typeof m !== 'object') return null; + const item = {}; + const type = String(m.type || '').trim().toLowerCase(); + item.type = EVIDENCE_MEDIA_TYPES.has(type) ? type : 'other'; + const p = evidenceString(m.path, 600); + if (!p) return null; + item.path = p; + const caption = evidenceString(m.caption, 300); + if (caption) item.caption = caption; + return item; + }).filter(Boolean); + if (media.length) out.media = media; + } + + if (Array.isArray(raw.data)) { + const data = raw.data.slice(0, 50).map((d) => { + if (!d || typeof d !== 'object') return null; + const metric = evidenceString(d.metric, 120); + if (!metric) return null; + const item = { metric }; + for (const key of ['before', 'after']) { + const v = d[key]; + if (v === undefined || v === null) continue; + if (typeof v === 'number' && Number.isFinite(v)) item[key] = v; + else { + const s = evidenceString(v, 120); + if (s) item[key] = s; + } + } + const note = evidenceString(d.note, 300); + if (note) item.note = note; + return item; + }).filter(Boolean); + if (data.length) out.data = data; + } + + if (Array.isArray(raw.reviews)) { + const reviews = raw.reviews.slice(0, 20).map((r) => { + if (!r || typeof r !== 'object') return null; + const item = {}; + const role = evidenceString(r.role, 60); + if (role) item.role = role.toLowerCase(); + const agentId = evidenceString(r.agentId, 40); + if (agentId) item.agentId = agentId.toLowerCase(); + const model = evidenceString(r.model, 80); + if (model) item.model = model; + const effort = evidenceString(r.effort, 20); + if (effort) item.effort = effort.toLowerCase(); + const verdict = normalizeReviewOutcome(r.verdict); + if (verdict) item.verdict = verdict; + const summary = evidenceString(r.summary, 2000); + if (summary) item.summary = summary; + const findings = evidenceCount(r.findings); + if (findings !== null) item.findings = findings; + const fixed = evidenceCount(r.fixed); + if (fixed !== null) item.fixed = fixed; + const at = normalizeDateTime(r.at); + if (at) item.at = at; + const by = evidenceString(r.by, 240); + if (by) item.by = by; + const url = evidenceString(r.url, 600); + if (url) item.url = url; + return Object.keys(item).length ? item : null; + }).filter(Boolean); + if (reviews.length) out.reviews = reviews; + } + + if (Array.isArray(raw.standards)) { + const standards = raw.standards + .map((s) => evidenceString(s, 200)) + .filter(Boolean) + .slice(0, 20); + if (standards.length) out.standards = [...new Set(standards)]; + } + + if (raw.handoff && typeof raw.handoff === 'object') { + const h = {}; + const notes = evidenceString(raw.handoff.notes, 4000); + if (notes) h.notes = notes; + const at = normalizeDateTime(raw.handoff.at); + if (at) h.at = at; + if (Object.keys(h).length) out.handoff = h; + } + + if (raw.diffStats && typeof raw.diffStats === 'object') { + const d = {}; + for (const key of ['files', 'additions', 'deletions']) { + const v = evidenceCount(raw.diffStats[key]); + if (v !== null) d[key] = v; + } + if (Object.keys(d).length) out.diffStats = d; + } + + // Trusted server-set root used by the media streaming endpoint; agent + // supplied blocks have this stripped before they reach normalization. + const worktreePath = evidenceString(raw.worktreePath, 500); + if (worktreePath) out.worktreePath = worktreePath; + + if (!Object.keys(out).length) return null; + + out.schema = 1; + out.updatedAt = normalizeDateTime(raw.updatedAt) || new Date().toISOString(); + return out; +}; + class TaskRecordService { constructor({ filePath } = {}) { this.filePath = filePath || DEFAULT_PATH; @@ -354,6 +574,24 @@ class TaskRecordService { } } + if (p.evidence !== undefined) { + if (p.evidence === null) { + clear.add('evidence'); + } else { + const normalized = normalizeEvidence(p.evidence); + if (normalized !== null) next.evidence = normalized; + } + } + + if (p.reviewWorkflow !== undefined) { + if (p.reviewWorkflow === null) { + clear.add('reviewWorkflow'); + } else { + const normalized = normalizeReviewWorkflow(p.reviewWorkflow); + if (normalized !== null) next.reviewWorkflow = normalized; + } + } + if (p.done !== undefined) { const done = !!p.done; if (done) next.doneAt = new Date().toISOString(); @@ -770,4 +1008,4 @@ class TaskRecordService { } } -module.exports = { TaskRecordService }; +module.exports = { TaskRecordService, normalizeEvidence, normalizeReviewWorkflow }; diff --git a/server/utils/shellSafety.js b/server/utils/shellSafety.js new file mode 100644 index 00000000..8872e846 --- /dev/null +++ b/server/utils/shellSafety.js @@ -0,0 +1,37 @@ +'use strict'; + +// Guards for values that get interpolated into shell command strings written +// to a PTY. Config files (custom-agents.json, .orchestrator-config.json) and +// workflow model/effort values are attacker-influenceable, so anything that +// reaches a shell command must be validated against a strict allowlist — a +// stray ";", "|", "$(", backtick, "&", redirection, or newline would otherwise +// let a malicious config run arbitrary commands. + +// Model ids: letters/digits and the punctuation real model names use +// (dots, dashes, underscore, slash, colon, @, and claude's "[1m]" suffix). +const MODEL_RE = /^[A-Za-z0-9._:@/\-[\]]{1,120}$/; + +// Reasoning/effort levels are short lowercase words. +const REASONING_RE = /^[a-z][a-z-]{0,20}$/; + +// CLI flag/arg tokens may contain spaces (e.g. "--sandbox workspace-write") +// but never shell metacharacters. +const FLAG_RE = /^[A-Za-z0-9 _\-=./:@,+]{0,200}$/; + +// Denylist for a fully-resolved command string as a last line of defense. +const DANGEROUS_SHELL = /[;&|`$<>\n\r\\]|\$\(|\|\||&&/; + +const isSafeModel = (v) => typeof v === 'string' && MODEL_RE.test(v); +const isSafeReasoning = (v) => typeof v === 'string' && REASONING_RE.test(v); +const isSafeFlag = (v) => typeof v === 'string' && FLAG_RE.test(v); +const hasDangerousShell = (v) => DANGEROUS_SHELL.test(String(v || '')); + +module.exports = { + MODEL_RE, + REASONING_RE, + FLAG_RE, + isSafeModel, + isSafeReasoning, + isSafeFlag, + hasDangerousShell +}; diff --git a/server/visibilityPresetService.js b/server/visibilityPresetService.js new file mode 100644 index 00000000..241f161e --- /dev/null +++ b/server/visibilityPresetService.js @@ -0,0 +1,116 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Visibility presets: one-click switch between the lean open-source default +// UI ("simple") and the full process/workflow layer ("power") that was +// hidden for the public release. Individual flags can still be hand-edited +// in user-settings.json afterwards — a preset just rewrites ui.visibility. + +const DEFAULTS_PATH = path.join(__dirname, '..', 'user-settings.default.json'); + +// Flags the "power" preset turns ON relative to the shipped defaults. +const POWER_OVERRIDES = { + processBanner: true, + header: { + prs: true, + queue: true, + reviewRoute: true, + activity: true, + diff: true, + commands: true, + workflowMode: true, + workflowBackground: true, + tierFilters: true, + focusTier2: true, + focusSwap: true, + history: true + }, + sidebar: { + viewPresets: true, + readyForReview: true, + sessionVisibilityToggles: true + }, + terminal: { + intentHints: false, // opt-in separately: calls a model API + startServer: true, + serverLaunchMenu: true, + launchSettings: true, + startServerDev: true + }, + dashboard: { + processBanner: true, + processSection: true, + statusCard: true, + telemetryCard: true, + projectsCard: true, + adviceCard: true, + readinessCard: true, + quickLinks: true + }, + commander: { + advice: true, + cmdMode: true, + modeSelect: true, + startStop: true, + startClaude: true, + tabs: true + } +}; + +const deepMerge = (base, override) => { + if (!override || typeof override !== 'object' || Array.isArray(override)) return override ?? base; + if (!base || typeof base !== 'object' || Array.isArray(base)) return { ...override }; + const out = { ...base }; + for (const [k, v] of Object.entries(override)) { + out[k] = deepMerge(base[k], v); + } + return out; +}; + +const readDefaultVisibility = () => { + try { + const parsed = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); + const vis = parsed?.global?.ui?.visibility; + return vis && typeof vis === 'object' ? vis : {}; + } catch { + return {}; + } +}; + +const PRESETS = { + simple: { + label: 'Simple', + description: 'Lean default UI. Queue-driven review, workflow controls hidden.' + }, + power: { + label: 'Power / Process', + description: 'Full workflow layer: process banner, workflow modes, tier filters, PRs, review route, activity, diff, dashboard process cards, commander controls.' + } +}; + +const buildPresetVisibility = (preset) => { + const defaults = readDefaultVisibility(); + if (preset === 'power') return deepMerge(defaults, POWER_OVERRIDES); + return defaults; +}; + +const listPresets = () => Object.entries(PRESETS).map(([id, p]) => ({ id, ...p })); + +const applyPreset = (userSettingsService, preset) => { + if (!PRESETS[preset]) { + throw new Error(`Unknown visibility preset: ${preset}. Valid: ${Object.keys(PRESETS).join(', ')}`); + } + const settings = userSettingsService.getAllSettings() || {}; + const global = settings.global || {}; + const ui = global.ui || {}; + ui.visibility = buildPresetVisibility(preset); + ui.visibilityPreset = preset; + global.ui = ui; + const ok = userSettingsService.updateGlobalSettings(global); + if (!ok) throw new Error('Failed to persist visibility preset'); + return { preset, visibility: ui.visibility }; +}; + +module.exports = { listPresets, applyPreset, buildPresetVisibility, POWER_OVERRIDES }; diff --git a/tests/unit/agentManager.customAgents.test.js b/tests/unit/agentManager.customAgents.test.js new file mode 100644 index 00000000..9ed23feb --- /dev/null +++ b/tests/unit/agentManager.customAgents.test.js @@ -0,0 +1,161 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const AgentManager = require('../../server/agentManager'); +const { spawnAgentInSession } = require('../../server/agentSpawnHelper'); + +const writeCustomAgents = (agents) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-agents-')); + const filePath = path.join(tmp, 'custom-agents.json'); + fs.writeFileSync(filePath, JSON.stringify({ agents })); + return filePath; +}; + +const GEMINI_LIKE = { + name: 'Gemini CLI', + baseCommand: 'gemini', + flags: { + yolo: { flag: '--yolo', label: 'YOLO', default: true } + }, + defaultFlags: ['yolo'], + modelFlag: '-m {model}', + reasoningFlag: '--effort {reasoning}', + initDelayMs: 12000 +}; + +describe('AgentManager custom agents', () => { + test('registers a custom agent from the config file', () => { + const manager = new AgentManager({ customAgentsPath: writeCustomAgents({ gemini: GEMINI_LIKE }) }); + + const agent = manager.getAgent('gemini'); + expect(agent).toBeTruthy(); + expect(agent.custom).toBe(true); + expect(agent.modes.fresh.command).toBe('gemini'); + expect(manager.getSpawnFlags('gemini')).toEqual(['yolo']); + expect(manager.getInitDelayMs('gemini')).toBe(12000); + // Built-ins survive alongside + expect(manager.getAgent('claude')).toBeTruthy(); + expect(manager.getAgent('codex')).toBeTruthy(); + }); + + test('buildCommand uses per-agent model/reasoning flag templates', () => { + const manager = new AgentManager({ customAgentsPath: writeCustomAgents({ gemini: GEMINI_LIKE }) }); + + const command = manager.buildCommand('gemini', 'fresh', { + agentId: 'gemini', + flags: ['yolo'], + model: 'gemini-2.5-pro', + reasoning: 'high' + }); + + expect(command).toBe('gemini -m gemini-2.5-pro --effort high --yolo'); + }); + + test('codex keeps its default -m / -c reasoning syntax', () => { + const manager = new AgentManager({ customAgentsPath: null }); + const command = manager.buildCommand('codex', 'fresh', { + agentId: 'codex', + flags: ['yolo'], + model: 'gpt-5.5', + reasoning: 'high' + }); + expect(command).toContain('-m gpt-5.5'); + expect(command).toContain('-c model_reasoning_effort="high"'); + expect(command).toContain('--dangerously-bypass-approvals-and-sandbox'); + }); + + test('reasoning is not appended for agents without reasoning support', () => { + const manager = new AgentManager({ + customAgentsPath: writeCustomAgents({ + plain: { baseCommand: 'plain-cli', modelFlag: '--model {model}' } + }) + }); + const command = manager.buildCommand('plain', 'fresh', { + agentId: 'plain', + flags: [], + model: 'x-1', + reasoning: 'high' + }); + expect(command).toBe('plain-cli --model x-1'); + }); + + test('cannot silently override built-ins without override flag', () => { + const manager = new AgentManager({ + customAgentsPath: writeCustomAgents({ + claude: { baseCommand: 'evil-claude' } + }) + }); + expect(manager.getAgent('claude').baseCommand).toBe('claude'); + }); + + test('validateConfig accepts custom agents and rejects unknown flags', () => { + const manager = new AgentManager({ customAgentsPath: writeCustomAgents({ gemini: GEMINI_LIKE }) }); + expect(manager.validateConfig({ agentId: 'gemini', mode: 'fresh', flags: ['yolo'] }).valid).toBe(true); + expect(manager.validateConfig({ agentId: 'gemini', mode: 'fresh', flags: ['nope'] }).valid).toBe(false); + }); +}); + +describe('spawnAgentInSession registry-driven behavior', () => { + test('uses the registry defaultFlags and init delay for custom agents', () => { + jest.useFakeTimers(); + const manager = new AgentManager({ customAgentsPath: writeCustomAgents({ gemini: GEMINI_LIKE }) }); + + const starts = []; + const writes = []; + const sessionManager = { + agentManager: manager, + startAgentWithConfig: (sessionId, config) => { starts.push({ sessionId, config }); return true; }, + writeToSession: (sessionId, data) => writes.push(data) + }; + + const ok = spawnAgentInSession({ + sessionManager, + sessionId: 'repo-work1-claude', + agentId: 'gemini', + model: 'gemini-2.5-flash', + effort: 'low', + prompt: 'review this' + }); + + expect(ok.started).toBe(true); + expect(starts[0].config).toEqual({ + agentId: 'gemini', + mode: 'fresh', + flags: ['yolo'], + model: 'gemini-2.5-flash', + reasoning: 'low' + }); + + jest.advanceTimersByTime(11_999); + expect(writes).toHaveLength(0); + jest.advanceTimersByTime(1); + expect(writes[0]).toBe('review this'); + jest.useRealTimers(); + }); + + test('cancelPendingPrompt stops the delayed prompt injection', () => { + jest.useFakeTimers(); + const manager = new AgentManager({ customAgentsPath: writeCustomAgents({ gemini: GEMINI_LIKE }) }); + + const writes = []; + const sessionManager = { + agentManager: manager, + startAgentWithConfig: () => true, + writeToSession: (sessionId, data) => writes.push(data) + }; + + const spawned = spawnAgentInSession({ + sessionManager, + sessionId: 'repo-work1-claude', + agentId: 'gemini', + prompt: 'review this' + }); + expect(spawned.started).toBe(true); + + spawned.cancelPendingPrompt(); + jest.advanceTimersByTime(60_000); + expect(writes).toHaveLength(0); + jest.useRealTimers(); + }); +}); diff --git a/tests/unit/batchLaunchService.prompt.test.js b/tests/unit/batchLaunchService.prompt.test.js new file mode 100644 index 00000000..2c5ee852 --- /dev/null +++ b/tests/unit/batchLaunchService.prompt.test.js @@ -0,0 +1,31 @@ +const { BatchLaunchService } = require('../../server/batchLaunchService'); +const { buildEvidencePromptSnippet } = require('../../server/evidencePromptSnippet'); +const { parseEvidenceBlocks } = require('../../server/evidenceService'); + +const makeService = (settings = {}) => new BatchLaunchService({ + userSettingsService: { getAllSettings: () => settings } +}); + +describe('BatchLaunchService prompt evidence snippet', () => { + const card = { name: 'Fix spawn bug', desc: 'The spawner double-fires.' }; + + test('appends the evidence protocol by default', () => { + const prompt = makeService()._buildPrompt({ card, cardUrl: 'https://trello.com/c/x', cardShortId: 'x' }); + expect(prompt).toContain('EVIDENCE PROTOCOL'); + expect(prompt).toContain('```agent-evidence'); + expect(prompt).toContain('The spawner double-fires.'); + }); + + test('can be disabled via settings', () => { + const prompt = makeService({ + global: { ui: { tasks: { evidencePromptEnabled: false } } } + })._buildPrompt({ card, cardUrl: '', cardShortId: '' }); + expect(prompt).not.toContain('EVIDENCE PROTOCOL'); + }); + + test('snippet example block round-trips through the evidence parser', () => { + const blocks = parseEvidenceBlocks(buildEvidencePromptSnippet()); + expect(blocks).toHaveLength(1); + expect(blocks[0].tests.command).toBe('npm test'); + }); +}); diff --git a/tests/unit/commanderManager.test.js b/tests/unit/commanderManager.test.js new file mode 100644 index 00000000..e0291ee7 --- /dev/null +++ b/tests/unit/commanderManager.test.js @@ -0,0 +1,94 @@ +const { CommanderManager, PRIMARY_ID, MAX_COMMANDERS } = require('../../server/commanderManager'); + +const makeManager = () => { + // Fresh manager (bypass the module singleton so tests don't leak state). + return new CommanderManager({ io: null, sessionManager: {} }); +}; + +describe('CommanderManager', () => { + test('always has a primary commander with id "commander"', () => { + const mgr = makeManager(); + expect(mgr.primary()).toBeTruthy(); + expect(mgr.primary().id).toBe(PRIMARY_ID); + expect(mgr.list().find(c => c.primary)?.id).toBe(PRIMARY_ID); + }); + + test('resolve() defaults to primary for empty/unknown ids (no ghost creation)', () => { + const mgr = makeManager(); + expect(mgr.resolve()).toBe(mgr.primary()); + expect(mgr.resolve('')).toBe(mgr.primary()); + expect(mgr.resolve('does-not-exist')).toBe(mgr.primary()); + // Unknown id did NOT create an instance + expect(mgr.list()).toHaveLength(1); + }); + + test('spawn() creates an independent instance with its own id and cwd', () => { + const mgr = makeManager(); + const second = mgr.spawn('research'); + expect(second.id).toBe('research'); + expect(second).not.toBe(mgr.primary()); + expect(second.cwd).toContain('commanders'); + expect(second.cwd).toContain('research'); + expect(mgr.resolve('research')).toBe(second); + expect(mgr.list()).toHaveLength(2); + }); + + test('spawn() is idempotent for an existing id', () => { + const mgr = makeManager(); + const a = mgr.spawn('research'); + const b = mgr.spawn('research'); + expect(a).toBe(b); + expect(mgr.list()).toHaveLength(2); + }); + + test('spawn() rejects bad ids and the reserved primary id', () => { + const mgr = makeManager(); + expect(() => mgr.spawn('commander')).toThrow(/primary/i); + expect(() => mgr.spawn('Has Spaces')).toThrow(/Invalid commander id/); + expect(() => mgr.spawn('UPPER')).not.toThrow(); // lowercased first + expect(mgr.has('upper')).toBe(true); + }); + + test('spawn() enforces the commander limit', () => { + const mgr = makeManager(); + for (let i = 1; i < MAX_COMMANDERS; i++) mgr.spawn(`c${i}`); + expect(mgr.list()).toHaveLength(MAX_COMMANDERS); + expect(() => mgr.spawn('one-too-many')).toThrow(/limit reached/i); + }); + + test('remove() tears down an additional commander but never the primary', async () => { + const mgr = makeManager(); + const second = mgr.spawn('research'); + second.stop = jest.fn(() => ({ success: true })); + + const result = await mgr.remove('research'); + expect(result.removed).toBe(true); + expect(second.stop).toHaveBeenCalled(); + expect(mgr.has('research')).toBe(false); + + await expect(mgr.remove('commander')).rejects.toThrow(/primary/i); + expect(mgr.primary()).toBeTruthy(); + }); + + test('remove() of an unknown id is a no-op', async () => { + const mgr = makeManager(); + const result = await mgr.remove('nope'); + expect(result.removed).toBe(false); + }); +}); + +describe('CommanderService instance identity', () => { + const { CommanderService } = require('../../server/commanderService'); + + test('emit() scopes the payload with commanderId', () => { + const emitted = []; + const svc = new CommanderService({ io: { emit: (event, payload) => emitted.push({ event, payload }) }, id: 'research' }); + svc.emit('commander-output', { data: 'hi' }); + expect(emitted[0]).toEqual({ event: 'commander-output', payload: { data: 'hi', commanderId: 'research' } }); + }); + + test('primary instance defaults to id "commander"', () => { + const svc = new CommanderService({ io: null }); + expect(svc.id).toBe('commander'); + }); +}); diff --git a/tests/unit/contextSwitchTelemetryService.test.js b/tests/unit/contextSwitchTelemetryService.test.js new file mode 100644 index 00000000..df4f4e4d --- /dev/null +++ b/tests/unit/contextSwitchTelemetryService.test.js @@ -0,0 +1,59 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ContextSwitchTelemetryService } = require('../../server/contextSwitchTelemetryService'); + +const makeService = () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-ctx-')); + return new ContextSwitchTelemetryService({ filePath: path.join(tmp, 'context-switches.jsonl') }); +}; + +describe('ContextSwitchTelemetryService', () => { + test('tracks events to JSONL and summarizes switches with cost estimate', () => { + const svc = makeService(); + expect(svc.track({ type: 'worktree-focus', from: 'work1', to: 'work2' }).ok).toBe(true); + expect(svc.track({ type: 'worktree-focus', from: 'work2', to: 'work3' }).ok).toBe(true); + expect(svc.track({ type: 'workflow-mode', from: 'focus', to: 'review' }).ok).toBe(true); + + const summary = svc.getSummary({ hours: 1 }); + expect(summary.switches).toBe(3); + expect(summary.estimatedCostMinutes).toBe(30); + expect(summary.byType['worktree-focus']).toBe(2); + expect(summary.topPairs[0].count).toBe(1); + }); + + test('rejects unknown event types', () => { + const svc = makeService(); + const result = svc.track({ type: 'keyboard-smash' }); + expect(result.ok).toBe(false); + }); + + test('dedupes identical rapid repeats', () => { + const svc = makeService(); + svc.track({ type: 'workspace-switch', from: 'a', to: 'b' }); + const second = svc.track({ type: 'workspace-switch', from: 'a', to: 'b' }); + expect(second.deduped).toBe(true); + expect(svc.getSummary({ hours: 1 }).switches).toBe(1); + }); + + test('same-context events do not count as switches', () => { + const svc = makeService(); + svc.track({ type: 'worktree-focus', from: 'work1', to: 'work1' }); + expect(svc.getSummary({ hours: 1 }).switches).toBe(0); + }); + + test('pairs review-start/review-end into review minutes', () => { + const svc = makeService(); + const start = new Date(Date.now() - 10 * 60_000).toISOString(); + const end = new Date(Date.now() - 4 * 60_000).toISOString(); + fs.mkdirSync(path.dirname(svc.filePath), { recursive: true }); + fs.writeFileSync(svc.filePath, [ + JSON.stringify({ at: start, type: 'review-start', to: 'pr:a/b#1' }), + JSON.stringify({ at: end, type: 'review-end', to: 'pr:a/b#1' }) + ].join('\n') + '\n'); + + const summary = svc.getSummary({ hours: 1 }); + expect(summary.reviewMinutes).toBe(6); + }); +}); diff --git a/tests/unit/evidenceService.test.js b/tests/unit/evidenceService.test.js new file mode 100644 index 00000000..2d788429 --- /dev/null +++ b/tests/unit/evidenceService.test.js @@ -0,0 +1,171 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { EvidenceService, parseEvidenceBlocks, mergeEvidence } = require('../../server/evidenceService'); +const { TaskRecordService } = require('../../server/taskRecordService'); + +const makeTaskRecords = () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-evsvc-')); + return new TaskRecordService({ filePath: path.join(tmp, 'task-records.json') }); +}; + +const fence = (obj) => '```agent-evidence\n' + JSON.stringify(obj, null, 2) + '\n```'; + +describe('parseEvidenceBlocks', () => { + test('extracts multiple fenced JSON blocks and skips malformed ones', () => { + const text = [ + 'Intro text', + fence({ summary: 'first' }), + '```agent-evidence\n{not json}\n```', + 'middle', + fence({ tests: { ran: true, passed: 3 } }) + ].join('\n\n'); + + const blocks = parseEvidenceBlocks(text); + expect(blocks).toHaveLength(2); + expect(blocks[0].summary).toBe('first'); + expect(blocks[1].tests.passed).toBe(3); + }); + + test('strips server-only worktreePath from agent blocks', () => { + const blocks = parseEvidenceBlocks(fence({ summary: 'sneaky', worktreePath: '/home/user/.ssh' })); + expect(blocks[0].worktreePath).toBeUndefined(); + }); + + test('returns empty for plain text and non-evidence fences', () => { + expect(parseEvidenceBlocks('```json\n{"a":1}\n```')).toHaveLength(0); + expect(parseEvidenceBlocks(undefined)).toHaveLength(0); + }); +}); + +describe('mergeEvidence', () => { + test('later blocks win for scalar sections; arrays accumulate with de-dupe', () => { + const merged = mergeEvidence( + { summary: 'old', tests: { ran: true, passed: 1 }, reviews: [{ role: 'security', verdict: 'needs_fix', at: '2026-07-15T00:00:00Z' }], media: [{ type: 'image', path: 'a.png' }] }, + { summary: 'new', reviews: [{ role: 'security', verdict: 'needs_fix', at: '2026-07-15T00:00:00Z' }, { role: 'general', verdict: 'approved' }], media: [{ type: 'image', path: 'a.png' }, { type: 'image', path: 'b.png' }] } + ); + + expect(merged.summary).toBe('new'); + expect(merged.tests.passed).toBe(1); + expect(merged.reviews).toHaveLength(2); + expect(merged.media.map(m => m.path)).toEqual(['a.png', 'b.png']); + }); + + test('returns null when nothing merges', () => { + expect(mergeEvidence(null, undefined, {})).toBeNull(); + }); +}); + +describe('EvidenceService.refresh (PR source)', () => { + test('collects evidence from PR body + comments and aggregates diff stats', async () => { + const taskRecordService = makeTaskRecords(); + const pullRequestService = { + getPullRequestDetailsByUrl: async () => ({ + pr: { headRefName: 'feature/x' }, + files: [ + { filename: 'a.js', additions: 10, deletions: 2 }, + { filename: 'b.js', additions: 5, deletions: 1 } + ], + conversation: { + issueComments: [ + { body: 'LGTM overall\n' + fence({ reviews: [{ role: 'security', agentId: 'codex', verdict: 'approved', findings: 1, fixed: 1 }] }) } + ], + reviews: [] + } + }), + getPullRequest: async () => ({ + body: 'My PR\n' + fence({ summary: 'Adds spawn system', tests: { ran: true, command: 'npm test', passed: 12, failed: 0 } }) + }) + }; + + const svc = new EvidenceService({ taskRecordService, pullRequestService }); + const result = await svc.refresh('pr:me/repo#5'); + + expect(result.updated).toBe(true); + expect(result.evidence.summary).toBe('Adds spawn system'); + expect(result.evidence.tests.passed).toBe(12); + expect(result.evidence.reviews[0]).toMatchObject({ role: 'security', agentId: 'codex', verdict: 'approved' }); + expect(result.evidence.diffStats).toEqual({ files: 2, additions: 15, deletions: 3 }); + + const persisted = taskRecordService.get('pr:me/repo#5'); + expect(persisted.evidence.summary).toBe('Adds spawn system'); + }); + + test('worktree file evidence merges for worktree tasks', async () => { + const taskRecordService = makeTaskRecords(); + const worktree = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-worktree-')); + fs.writeFileSync(path.join(worktree, '.agent-evidence.json'), JSON.stringify({ + summary: 'local run', + appRun: { ran: true, method: 'server-smoke' }, + media: [{ type: 'image', path: '.agent-evidence/shot.png' }] + })); + + const svc = new EvidenceService({ taskRecordService }); + const result = await svc.refresh(`worktree:${worktree}`); + + expect(result.updated).toBe(true); + expect(result.evidence.appRun.method).toBe('server-smoke'); + expect(result.evidence.worktreePath).toBe(worktree); + }); +}); + +describe('EvidenceService.resolveMediaPath', () => { + const setup = async () => { + const taskRecordService = makeTaskRecords(); + const worktree = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-media-')); + fs.mkdirSync(path.join(worktree, '.agent-evidence'), { recursive: true }); + fs.writeFileSync(path.join(worktree, '.agent-evidence', 'shot.png'), 'fake-png'); + await taskRecordService.upsert('task:media', { + evidence: { + media: [ + { type: 'image', path: '.agent-evidence/shot.png' }, + { type: 'other', path: '../../etc/passwd' }, + { type: 'other', path: '.agent-evidence/script.sh' } + ], + worktreePath: worktree + } + }); + // resolveMediaPath re-validates worktreePath against the managed-worktree + // set at read time, so the fixture worktree must be a managed one. + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1' }), + getWorkspaceById: () => ({ + terminals: [{ repository: { path: path.dirname(worktree) }, worktreeId: path.basename(worktree) }] + }) + }; + return { svc: new EvidenceService({ taskRecordService, workspaceManager }), worktree }; + }; + + test('resolves a valid media file inside the worktree', async () => { + const { svc, worktree } = await setup(); + const result = svc.resolveMediaPath('task:media', 0); + expect(result.path).toBe(path.join(worktree, '.agent-evidence', 'shot.png')); + }); + + test('rejects traversal outside the worktree', async () => { + const { svc } = await setup(); + const result = svc.resolveMediaPath('task:media', 1); + expect(result.status).toBe(403); + }); + + test('rejects disallowed extensions and bad indexes', async () => { + const { svc } = await setup(); + expect(svc.resolveMediaPath('task:media', 2).status).toBe(415); + expect(svc.resolveMediaPath('task:media', 99).status).toBe(404); + }); + + test('rejects a worktreePath outside the managed set (e.g. planted via raw task-record PUT)', async () => { + const taskRecordService = makeTaskRecords(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-unmanaged-')); + fs.writeFileSync(path.join(outside, 'leak.png'), 'fake-png'); + // The generic PUT /api/process/task-records/:id accepts a raw evidence + // object — the media endpoint must not serve from a directory that isn't + // one of the orchestrator's own worktrees, however the record was written. + await taskRecordService.upsert('task:evil', { + evidence: { media: [{ type: 'image', path: 'leak.png' }], worktreePath: outside } + }); + const svc = new EvidenceService({ taskRecordService }); + expect(svc.resolveMediaPath('task:evil', 0).status).toBe(403); + }); +}); diff --git a/tests/unit/pluginLoaderService.test.js b/tests/unit/pluginLoaderService.test.js index 3bca22f1..c74fd7c4 100644 --- a/tests/unit/pluginLoaderService.test.js +++ b/tests/unit/pluginLoaderService.test.js @@ -268,3 +268,82 @@ describe('PluginLoaderService', () => { expect(String(status.failed[0].error || '')).toContain('Duplicate client slot id'); }); }); + +describe('PluginLoaderService post_route actions + real example plugin', () => { + function writePlugin(tmpDir, pluginId, { manifest = null, serverSource }) { + const pluginDir = path.join(tmpDir, pluginId); + fs.mkdirSync(pluginDir, { recursive: true }); + if (manifest) { + fs.writeFileSync(path.join(pluginDir, 'plugin.json'), JSON.stringify(manifest, null, 2)); + } + fs.writeFileSync(path.join(pluginDir, 'server.js'), serverSource); + } + + const NOOP_SERVER = 'module.exports = async function register() {};'; + + test('accepts a valid post_route slot action', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-plugin-test-')); + writePlugin(tmpDir, 'postroute', { + manifest: { + name: 'Post route plugin', + version: '0.1.0', + client: { + slots: [{ + id: 'run-it', + slot: 'commander.tools', + label: 'Run', + action: { type: 'post_route', route: '/api/plugins/postroute/run', prompt: 'Value:', field: 'url' } + }] + } + }, + serverSource: NOOP_SERVER + }); + + const service = new PluginLoaderService({ pluginsDir: tmpDir, logger: { info: () => {}, warn: () => {}, error: () => {} } }); + const status = await service.loadAll({ app: express(), commandRegistry: { register: jest.fn(), getCommand: jest.fn(() => null) }, services: {} }); + + expect(status.failed).toHaveLength(0); + const slot = status.loaded[0].client.slots[0]; + expect(slot.action).toEqual({ type: 'post_route', route: '/api/plugins/postroute/run', prompt: 'Value:', field: 'url' }); + }); + + test('rejects post_route actions with non-local routes or bad field names', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-plugin-test-')); + writePlugin(tmpDir, 'badpostroute', { + manifest: { + name: 'Bad post route', + version: '0.1.0', + client: { + slots: [{ + id: 'bad', + slot: 'commander.tools', + label: 'Bad', + action: { type: 'post_route', route: 'https://evil.example/exfil' } + }] + } + }, + serverSource: NOOP_SERVER + }); + + const service = new PluginLoaderService({ pluginsDir: tmpDir, logger: { info: () => {}, warn: () => {}, error: () => {} } }); + const status = await service.loadAll({ app: express(), commandRegistry: { register: jest.fn(), getCommand: jest.fn(() => null) }, services: {} }); + + expect(status.loaded).toHaveLength(0); + expect(String(status.failed[0].error || '')).toMatch(/post_route action route/); + }); + + test('the shipped youtube-transcript plugin loads cleanly', async () => { + const service = new PluginLoaderService({ + pluginsDir: path.join(__dirname, '..', '..', 'plugins'), + logger: { info: () => {}, warn: () => {}, error: () => {} } + }); + const commandRegistry = { register: jest.fn(), getCommand: jest.fn(() => null) }; + const status = await service.loadAll({ app: express(), commandRegistry, services: {} }); + + const yt = status.loaded.find(p => p.id === 'youtube-transcript'); + expect(yt).toBeTruthy(); + expect(status.failed.find(p => (p.id || '') === 'youtube-transcript')).toBeFalsy(); + expect(commandRegistry.register).toHaveBeenCalledWith('youtube-transcript-transcribe', expect.any(Object)); + expect(yt.client.slots[0].slot).toBe('commander.tools'); + }); +}); diff --git a/tests/unit/prReviewAutomationService.spawn.test.js b/tests/unit/prReviewAutomationService.spawn.test.js new file mode 100644 index 00000000..ea2e87db --- /dev/null +++ b/tests/unit/prReviewAutomationService.spawn.test.js @@ -0,0 +1,247 @@ +const { PrReviewAutomationService } = require('../../server/prReviewAutomationService'); + +const buildDeps = ({ agent = 'claude' } = {}) => { + const writes = []; + const starts = []; + const upserts = []; + + const sessionManager = { + startAgentWithConfig: (sessionId, config) => { + starts.push({ sessionId, config }); + return true; + }, + writeToSession: (sessionId, data) => { + writes.push({ sessionId, data }); + }, + getSessionById: () => null, + getAllSessions: () => new Map() + }; + + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1' }), + getWorkspaceById: () => ({ + terminals: [ + { worktreeId: 'work3', repository: { name: 'local-repo-name' } } + ] + }) + }; + + const taskRecordService = { + upsert: (id, patch) => { + upserts.push({ id, patch }); + return Promise.resolve({ id, ...patch }); + }, + get: () => null, + list: () => [] + }; + + const userSettingsService = { + getAllSettings: () => ({ + global: { ui: { tasks: { automations: { prReview: { enabled: true, reviewerAgent: agent } } } } } + }) + }; + + const svc = new PrReviewAutomationService({ + sessionManager, + workspaceManager, + taskRecordService, + userSettingsService + }); + + return { svc, writes, starts, upserts }; +}; + +describe('PrReviewAutomationService reviewer spawn', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('spawns claude reviewer with valid agent config (agentId + flags)', async () => { + const { svc, starts, writes } = buildDeps({ agent: 'claude' }); + const ok = await svc._spawnReviewerForPr( + { owner: 'me', repo: 'gh-repo', number: 7, title: 'Test PR', prId: 'pr:me/gh-repo#7' }, + svc.getConfig() + ); + + expect(ok).toBe(true); + expect(starts).toHaveLength(1); + // Session id must use the LOCAL repo name from the workspace terminal, + // not the GitHub repo slug. + expect(starts[0].sessionId).toBe('local-repo-name-work3-claude'); + expect(starts[0].config).toEqual({ + agentId: 'claude', + mode: 'fresh', + flags: ['skipPermissions'] + }); + + // Prompt is written after init delay, submit ("\r") is a separate write. + jest.advanceTimersByTime(8_000); + expect(writes).toHaveLength(1); + expect(writes[0].data).toContain('PR #7'); + expect(writes[0].data.endsWith('\n')).toBe(false); + + jest.advanceTimersByTime(500); + expect(writes).toHaveLength(2); + expect(writes[1].data).toBe('\r'); + }); + + test('spawns codex reviewer with yolo flag and longer init delay', async () => { + const { svc, starts, writes } = buildDeps({ agent: 'codex' }); + const ok = await svc._spawnReviewerForPr( + { owner: 'me', repo: 'gh-repo', number: 8, title: 'Codex PR', prId: 'pr:me/gh-repo#8' }, + svc.getConfig() + ); + + expect(ok).toBe(true); + expect(starts[0].config).toEqual({ + agentId: 'codex', + mode: 'fresh', + flags: ['yolo'] + }); + + jest.advanceTimersByTime(8_000); + expect(writes).toHaveLength(0); + jest.advanceTimersByTime(7_000); + expect(writes).toHaveLength(1); + }); + + test('records reviewer spawn metadata on the task record', async () => { + const { svc, upserts } = buildDeps(); + await svc._spawnReviewerForPr( + { owner: 'me', repo: 'gh-repo', number: 9, title: 'Meta PR', prId: 'pr:me/gh-repo#9' }, + svc.getConfig() + ); + + const patch = upserts.find(u => u.id === 'pr:me/gh-repo#9')?.patch || {}; + expect(patch.reviewerWorktreeId).toBe('work3'); + expect(typeof patch.reviewerSpawnedAt).toBe('string'); + expect(typeof patch.reviewStartedAt).toBe('string'); + }); + + test('returns false when no worktree is available', async () => { + const { svc, starts } = buildDeps(); + svc.workspaceManager.getWorkspaceById = () => ({ terminals: [] }); + + const ok = await svc._spawnReviewerForPr( + { owner: 'me', repo: 'gh-repo', number: 10, prId: 'pr:me/gh-repo#10' }, + svc.getConfig() + ); + + expect(ok).toBe(false); + expect(starts).toHaveLength(0); + }); +}); + +describe('PrReviewAutomationService feedback routing (prompt-cache freshness)', () => { + const buildFeedbackDeps = ({ promptSentAt, autoSpawnFixer = true, handoffNotes = '' } = {}) => { + const writes = []; + const starts = []; + const upserts = []; + + const sessions = new Map([ + ['gh-repo-work1-claude', { status: 'busy' }] + ]); + + const sessionManager = { + startAgentWithConfig: (sessionId, config) => { starts.push({ sessionId, config }); return true; }, + writeToSession: (sessionId, data) => { writes.push({ sessionId, data }); }, + getSessionById: () => null, + getAllSessions: () => sessions + }; + + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1' }), + getWorkspaceById: () => ({ + terminals: [{ worktreeId: 'work9', repository: { name: 'gh-repo' } }] + }) + }; + + const record = { + promptSentAt, + evidence: handoffNotes ? { handoff: { notes: handoffNotes } } : undefined + }; + + const taskRecordService = { + get: () => record, + upsert: (id, patch) => { upserts.push({ id, patch }); return Promise.resolve({ id, ...patch }); }, + list: () => [] + }; + + const userSettingsService = { + getAllSettings: () => ({ + global: { ui: { tasks: { automations: { prReview: { + enabled: true, autoFeedbackToAuthor: true, autoSpawnFixer + } } } } } + }) + }; + + const svc = new PrReviewAutomationService({ sessionManager, workspaceManager, taskRecordService, userSettingsService }); + return { svc, writes, starts, upserts }; + }; + + test('warm cache: feedback goes into the original session', async () => { + const { svc, writes, starts } = buildFeedbackDeps({ + promptSentAt: new Date(Date.now() - 10 * 60_000).toISOString() + }); + + await svc._sendFeedbackToAuthor('pr:me/gh-repo#4', { number: 4, reviewBody: 'fix it', reviewUser: 'bot' }, svc.getConfig()); + + expect(writes).toHaveLength(1); + expect(writes[0].sessionId).toBe('gh-repo-work1-claude'); + expect(writes[0].data).toContain('CHANGES REQUESTED'); + expect(starts).toHaveLength(0); + }); + + test('cold cache: spawns a fresh fixer seeded with handoff notes instead', async () => { + const { svc, writes, starts, upserts } = buildFeedbackDeps({ + promptSentAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + handoffNotes: 'branch feature/x; polish remaining' + }); + + await svc._sendFeedbackToAuthor('pr:me/gh-repo#5', { number: 5, reviewBody: 'edge case broken', reviewUser: 'bot' }, svc.getConfig()); + + expect(starts).toHaveLength(1); + expect(starts[0].sessionId).toBe('gh-repo-work9-claude'); + expect(starts[0].config.mode).toBe('fresh'); + expect(writes).toHaveLength(0); // nothing written into the stale session + + const fixerPatch = upserts.find(u => u.patch.fixerWorktreeId)?.patch; + expect(fixerPatch.fixerWorktreeId).toBe('work9'); + }); + + test('cold cache with fixer disabled: still delivers to the stale session', async () => { + const { svc, writes, starts } = buildFeedbackDeps({ + promptSentAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + autoSpawnFixer: false + }); + + await svc._sendFeedbackToAuthor('pr:me/gh-repo#6', { number: 6, reviewBody: 'nit', reviewUser: 'bot' }, svc.getConfig()); + + expect(starts).toHaveLength(0); + expect(writes).toHaveLength(1); + }); + + test('fresh fixer prompt contains feedback and handoff notes', async () => { + const { svc, starts } = buildFeedbackDeps({ + promptSentAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(), + handoffNotes: 'the tricky part is the retry loop' + }); + + jest.useFakeTimers(); + const writes = []; + svc.sessionManager.writeToSession = (sessionId, data) => writes.push({ sessionId, data }); + + await svc._sendFeedbackToAuthor('pr:me/gh-repo#7', { number: 7, reviewBody: 'race condition in retry' }, svc.getConfig()); + expect(starts).toHaveLength(1); + + jest.advanceTimersByTime(8_000); + expect(writes[0].data).toContain('race condition in retry'); + expect(writes[0].data).toContain('the tricky part is the retry loop'); + expect(writes[0].data).toContain('gh pr checkout 7'); + jest.useRealTimers(); + }); +}); diff --git a/tests/unit/reviewWorkflowService.test.js b/tests/unit/reviewWorkflowService.test.js new file mode 100644 index 00000000..d445265f --- /dev/null +++ b/tests/unit/reviewWorkflowService.test.js @@ -0,0 +1,244 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ReviewWorkflowService } = require('../../server/reviewWorkflowService'); +const { TaskRecordService } = require('../../server/taskRecordService'); +const { EvidenceService } = require('../../server/evidenceService'); + +const WORKFLOW_CONFIG = { + version: 1, + roles: { + general: { label: 'General reviewer', focusBullets: ['Correctness'] }, + security: { label: 'Security reviewer', focusBullets: ['Injection'] } + }, + workflows: { + standard: { label: 'Standard', stages: [{ role: 'general', agentId: 'claude', model: 'sonnet' }] }, + hardened: { + label: 'Hardened', + stages: [ + { role: 'security', agentId: 'codex', model: 'gpt-5.5', effort: 'high' }, + { role: 'general', agentId: 'claude', model: 'opus' } + ] + } + }, + riskDefaults: { low: 'standard', high: 'hardened' }, + stageTimeoutMinutes: 45 +}; + +const build = ({ reviews = [] } = {}) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-wf-')); + const configPath = path.join(tmp, 'review-workflows.json'); + fs.writeFileSync(configPath, JSON.stringify(WORKFLOW_CONFIG)); + + const taskRecordService = new TaskRecordService({ filePath: path.join(tmp, 'task-records.json') }); + + const starts = []; + const sessionManager = { + startAgentWithConfig: (sessionId, config) => { + starts.push({ sessionId, config }); + return true; + }, + writeToSession: () => {}, + getSessionById: () => null + }; + + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1' }), + getWorkspaceById: () => ({ + terminals: [ + { worktreeId: 'work5', repository: { name: 'repo-local' } }, + { worktreeId: 'work6', repository: { name: 'repo-local' } } + ] + }) + }; + + const prReviews = { list: reviews }; + const pullRequestService = { + getPullRequest: async () => ({ reviews: prReviews.list }) + }; + + const evidenceService = new EvidenceService({ + taskRecordService, + pullRequestService: { + getPullRequestDetailsByUrl: async () => ({ files: [], conversation: { issueComments: [], reviews: [] } }), + getPullRequest: async () => ({ body: '' }) + } + }); + + const svc = new ReviewWorkflowService({ + taskRecordService, + pullRequestService, + sessionManager, + workspaceManager, + evidenceService, + configPath, + userConfigPath: path.join(tmp, 'user-override.json') + }); + + return { svc, taskRecordService, starts, prReviews, tmp }; +}; + +afterEach(() => { + // Stop any pollers a test started. + if (ReviewWorkflowService.instance) ReviewWorkflowService.instance = null; +}); + +describe('ReviewWorkflowService config', () => { + test('loads config and merges user override', () => { + const { svc, tmp } = build(); + fs.writeFileSync(path.join(tmp, 'user-override.json'), JSON.stringify({ + riskDefaults: { low: 'hardened' }, + workflows: { standard: { label: 'Renamed' } } + })); + + const cfg = svc.getConfig({ force: true }); + expect(cfg.riskDefaults.low).toBe('hardened'); + expect(cfg.workflows.standard.label).toBe('Renamed'); + expect(cfg.workflows.standard.stages).toHaveLength(1); + expect(svc.getWorkflowForRisk('high')).toBe('hardened'); + }); +}); + +describe('ReviewWorkflowService runs', () => { + test('startWorkflow spawns stage 1 with per-role agent/model/effort', async () => { + const { svc, starts } = build(); + const run = await svc.startWorkflow('pr:me/repo#12', 'hardened'); + svc.stopPolling(); + + expect(run.status).toBe('running'); + expect(run.stageIndex).toBe(0); + expect(run.stages[0]).toMatchObject({ role: 'security', status: 'running', worktreeId: 'work5' }); + + expect(starts).toHaveLength(1); + expect(starts[0].sessionId).toBe('repo-local-work5-claude'); + expect(starts[0].config).toMatchObject({ + agentId: 'codex', + mode: 'fresh', + flags: ['yolo'], + model: 'gpt-5.5', + reasoning: 'high' + }); + }); + + test('approved review advances to the next stage and records evidence', async () => { + const { svc, taskRecordService, starts, prReviews } = build(); + await svc.startWorkflow('pr:me/repo#12', 'hardened'); + svc.stopPolling(); + + prReviews.list = [{ + state: 'APPROVED', + submittedAt: new Date(Date.now() + 1000).toISOString(), + body: 'No injection issues found.', + author: { login: 'reviewer-bot' } + }]; + + await svc.pollActiveRuns(); + svc.stopPolling(); + + const run = svc.getRun('pr:me/repo#12'); + expect(run.stages[0]).toMatchObject({ status: 'done', verdict: 'approved' }); + expect(run.stages[1].status).toBe('running'); + expect(run.stageIndex).toBe(1); + expect(starts).toHaveLength(2); + expect(starts[1].config).toMatchObject({ agentId: 'claude', model: 'opus' }); + + const evidence = taskRecordService.get('pr:me/repo#12').evidence; + expect(evidence.reviews).toHaveLength(1); + expect(evidence.reviews[0]).toMatchObject({ role: 'security', verdict: 'approved', by: 'reviewer-bot' }); + }); + + test('changes_requested blocks the workflow for fixing', async () => { + const { svc, prReviews } = build(); + await svc.startWorkflow('pr:me/repo#3', 'standard'); + svc.stopPolling(); + + prReviews.list = [{ + state: 'CHANGES_REQUESTED', + submittedAt: new Date(Date.now() + 1000).toISOString(), + body: 'Broken edge case.' + }]; + + await svc.pollActiveRuns(); + svc.stopPolling(); + + const run = svc.getRun('pr:me/repo#3'); + expect(run.status).toBe('blocked_fix'); + expect(run.stages[0].verdict).toBe('needs_fix'); + }); + + test('final stage approval completes the workflow', async () => { + const { svc, prReviews } = build(); + await svc.startWorkflow('pr:me/repo#4', 'standard'); + svc.stopPolling(); + + prReviews.list = [{ + state: 'APPROVED', + submittedAt: new Date(Date.now() + 1000).toISOString(), + body: 'LGTM' + }]; + + await svc.pollActiveRuns(); + svc.stopPolling(); + + const run = svc.getRun('pr:me/repo#4'); + expect(run.status).toBe('complete'); + expect(typeof run.completedAt).toBe('string'); + }); + + test('stage timeout stalls the run', async () => { + const { svc, taskRecordService } = build(); + await svc.startWorkflow('pr:me/repo#5', 'standard'); + svc.stopPolling(); + + // Backdate the spawn beyond the timeout. + const run = svc.getRun('pr:me/repo#5'); + const past = new Date(Date.now() - 60 * 60_000).toISOString(); + await taskRecordService.upsert('pr:me/repo#5', { + reviewWorkflow: { ...run, stages: [{ ...run.stages[0], spawnedAt: past }] } + }); + + await svc.pollActiveRuns(); + svc.stopPolling(); + + const after = svc.getRun('pr:me/repo#5'); + expect(after.status).toBe('stalled'); + expect(after.stages[0].status).toBe('failed'); + }); + + test('advanceWorkflow skips a stalled stage', async () => { + const { svc, prReviews } = build(); + await svc.startWorkflow('pr:me/repo#6', 'hardened'); + svc.stopPolling(); + + await svc.advanceWorkflow('pr:me/repo#6'); + svc.stopPolling(); + + const run = svc.getRun('pr:me/repo#6'); + expect(run.stages[0].status).toBe('skipped'); + expect(run.stages[1].status).toBe('running'); + expect(run.stageIndex).toBe(1); + expect(prReviews.list).toHaveLength(0); + }); + + test('stage prompt includes role focus, evidence instructions and prior verdicts', async () => { + const { svc } = build(); + const prompt = svc._buildStagePrompt({ + owner: 'me', + repo: 'repo', + number: 9, + title: 'Add thing', + stage: { role: 'security', agentId: 'codex', model: 'gpt-5.5' }, + stageIndex: 1, + stageCount: 2, + priorStages: [{ role: 'general', verdict: 'approved' }], + standards: ['CLAUDE.md'] + }); + + expect(prompt).toContain('Security reviewer'); + expect(prompt).toContain('agent-evidence'); + expect(prompt).toContain('gh pr review 9'); + expect(prompt).toContain('- general: approved'); + expect(prompt).toContain('READ-ONLY'); + }); +}); diff --git a/tests/unit/securityFixes.test.js b/tests/unit/securityFixes.test.js new file mode 100644 index 00000000..903dba69 --- /dev/null +++ b/tests/unit/securityFixes.test.js @@ -0,0 +1,141 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const AgentManager = require('../../server/agentManager'); +const { resolveServerLaunchCommand } = require('../../server/serverLaunchCommandResolver'); +const { EvidenceService } = require('../../server/evidenceService'); +const { TaskRecordService } = require('../../server/taskRecordService'); +const { isSafeModel, isSafeFlag, hasDangerousShell } = require('../../server/utils/shellSafety'); + +const writeAgents = (agents) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-sec-')); + const p = path.join(tmp, 'custom-agents.json'); + fs.writeFileSync(p, JSON.stringify({ agents })); + return p; +}; + +describe('shell-injection guards (Codex findings #1, #2)', () => { + test('malicious model value is dropped, not interpolated into the command', () => { + const mgr = new AgentManager({ customAgentsPath: null }); + const cmd = mgr.buildCommand('codex', 'fresh', { + agentId: 'codex', flags: ['yolo'], model: 'x; touch /tmp/pwned' + }); + expect(cmd).not.toContain('touch'); + expect(cmd).not.toContain(';'); + }); + + test('custom agent with an injecting flag is rejected at load', () => { + const mgr = new AgentManager({ + customAgentsPath: writeAgents({ + evil: { baseCommand: 'x', flags: { bad: { flag: '--f; rm -rf /' } } } + }) + }); + expect(mgr.getAgent('evil')).toBeUndefined(); + }); + + test('custom agent with an injecting modelFlag template is rejected at load', () => { + const mgr = new AgentManager({ + customAgentsPath: writeAgents({ + evil: { baseCommand: 'x', modelFlag: '-m {model}; curl evil' } + }) + }); + expect(mgr.getAgent('evil')).toBeUndefined(); + }); + + test('serverLaunchCommandResolver drops shell metacharacters in config flags', async () => { + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1', type: 'hytopia-game' }), + getWorkspaceById: () => ({ terminals: { pairs: [{ worktreeId: 'work1', repository: { name: 'g', type: 'hytopia-game' } }] } }), + getCascadedConfigForWorktree: async () => ({ + serverCommand: 'hytopia start {{gameMode}} {{commonFlags}}', + gameModes: { evil: { flag: '--mode=x; curl evil.example' } }, + commonFlags: { ok: { flag: '--safe' } } + }) + }; + const result = await resolveServerLaunchCommand({ + workspaceManager, sessionId: 'g-work1-server', environment: 'evil', + launchSettings: { flags: { ok: true } } + }); + expect(result.command).not.toContain('curl'); + expect(result.command).not.toContain(';'); + expect(result.command).toContain('--safe'); + expect(hasDangerousShell(result.command)).toBe(false); + }); + + test('serverCommand template with metacharacters falls back to the safe default', async () => { + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1', type: 'website' }), + getWorkspaceById: () => ({ terminals: { pairs: [{ worktreeId: 'work1', repository: { name: 'g', type: 'website' } }] } }), + getCascadedConfigForWorktree: async () => ({ serverCommand: 'npm run dev; curl evil' }) + }; + const result = await resolveServerLaunchCommand({ workspaceManager, sessionId: 'g-work1-server', environment: 'development' }); + expect(result.command).toBe('npm run dev'); + }); + + test('shellSafety validators', () => { + expect(isSafeModel('claude-opus-4-8[1m]')).toBe(true); + expect(isSafeModel('x; rm -rf /')).toBe(false); + expect(isSafeFlag('--sandbox workspace-write')).toBe(true); + expect(isSafeFlag('--x`whoami`')).toBe(false); + }); +}); + +describe('evidence media path safety (Codex findings #3, #4)', () => { + const setup = async ({ registerWorktree = true } = {}) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-evsec-')); + const worktree = path.join(tmp, 'repo', 'work1'); + fs.mkdirSync(path.join(worktree, '.agent-evidence'), { recursive: true }); + fs.writeFileSync(path.join(worktree, '.agent-evidence', 'shot.png'), 'png'); + // secret outside the worktree + a symlink to it from inside + fs.writeFileSync(path.join(tmp, 'secret.png'), 'SECRET'); + fs.symlinkSync(path.join(tmp, 'secret.png'), path.join(worktree, '.agent-evidence', 'leak.png')); + + const taskRecordService = new TaskRecordService({ filePath: path.join(tmp, 'task-records.json') }); + const workspaceManager = { + getActiveWorkspace: () => ({ id: 'ws1' }), + getWorkspaceById: () => ({ + terminals: registerWorktree + ? [{ repository: { path: path.join(tmp, 'repo') }, worktreeId: 'work1' }] + : [] + }) + }; + const svc = new EvidenceService({ taskRecordService, workspaceManager }); + return { svc, taskRecordService, worktree, tmp }; + }; + + test('a symlink escaping the worktree is rejected (no secret served)', async () => { + const { svc, taskRecordService, worktree } = await setup(); + await taskRecordService.upsert('task:x', { + evidence: { media: [{ type: 'image', path: '.agent-evidence/leak.png' }], worktreePath: worktree } + }); + const result = svc.resolveMediaPath('task:x', 0); + expect(result.error).toBeTruthy(); + expect([403, 404]).toContain(result.status); + }); + + test('a legitimate file inside the worktree still resolves', async () => { + const { svc, taskRecordService, worktree } = await setup(); + await taskRecordService.upsert('task:x', { + evidence: { media: [{ type: 'image', path: '.agent-evidence/shot.png' }], worktreePath: worktree } + }); + const result = svc.resolveMediaPath('task:x', 0); + expect(result.path).toBeTruthy(); + expect(fs.readFileSync(result.path, 'utf8')).toBe('png'); + }); + + test('refresh() ignores an explicit worktreePath that is not a known worktree', async () => { + const { svc, tmp } = await setup({ registerWorktree: false }); + const result = await svc.refresh('task:y', { worktreePath: path.join(tmp, 'repo', 'work1') }); + // not a registered worktree -> no trusted root persisted + expect(result.evidence?.worktreePath).toBeUndefined(); + }); + + test('refresh() accepts a worktreePath that IS a known workspace worktree', async () => { + const { svc, worktree } = await setup({ registerWorktree: true }); + fs.writeFileSync(path.join(worktree, '.agent-evidence.json'), JSON.stringify({ summary: 'local' })); + const result = await svc.refresh('task:z', { worktreePath: worktree }); + expect(result.updated).toBe(true); + expect(result.evidence.summary).toBe('local'); + }); +}); diff --git a/tests/unit/serverLaunchCommandResolver.test.js b/tests/unit/serverLaunchCommandResolver.test.js new file mode 100644 index 00000000..9d17c2b4 --- /dev/null +++ b/tests/unit/serverLaunchCommandResolver.test.js @@ -0,0 +1,73 @@ +const { resolveServerLaunchCommand } = require('../../server/serverLaunchCommandResolver'); + +const makeWorkspaceManager = ({ type = 'hytopia-game', cascaded = null } = {}) => ({ + getActiveWorkspace: () => ({ id: 'ws1', type }), + getWorkspaceById: () => ({ + terminals: { + pairs: [ + { worktreeId: 'work2', repository: { name: 'zoo-game', type } } + ] + } + }), + getCascadedConfigForWorktree: async () => cascaded +}); + +describe('resolveServerLaunchCommand', () => { + test('substitutes gameMode and commonFlags into the configured template', async () => { + const workspaceManager = makeWorkspaceManager({ + cascaded: { + serverCommand: 'hytopia start {{gameMode}} {{commonFlags}}', + gameModes: { deathmatch: { flag: '--mode=deathmatch', label: 'Deathmatch' } }, + commonFlags: { + unlockAll: { flag: '--unlock-all', label: 'Unlock All' }, + debug: { flag: '--debug', label: 'Debug' } + } + } + }); + + const result = await resolveServerLaunchCommand({ + workspaceManager, + sessionId: 'zoo-game-work2-server', + cwd: '/repo/work2', + environment: 'deathmatch', + launchSettings: { flags: { unlockAll: true, debug: false } } + }); + + expect(result.command).toBe('hytopia start --mode=deathmatch --unlock-all'); + expect(result.usedGameMode).toBe('deathmatch'); + }); + + test('falls back to hytopia default for hytopia-game type without config', async () => { + const workspaceManager = makeWorkspaceManager({ cascaded: null }); + const result = await resolveServerLaunchCommand({ + workspaceManager, + sessionId: 'zoo-game-work2-server', + environment: 'development' + }); + expect(result.command).toBe('hytopia start'); + }); + + test('falls back to npm run dev for unknown types and appends gameArgs', async () => { + const workspaceManager = makeWorkspaceManager({ type: 'website', cascaded: {} }); + const result = await resolveServerLaunchCommand({ + workspaceManager, + sessionId: 'my-site-work1-server', + environment: 'development', + launchSettings: { gameArgs: '--host 0.0.0.0' } + }); + expect(result.command).toBe('npm run dev --host 0.0.0.0'); + }); + + test('unknown environment keys leave the template placeholders empty', async () => { + const workspaceManager = makeWorkspaceManager({ + cascaded: { serverCommand: 'hytopia start {{gameMode}}', gameModes: {} } + }); + const result = await resolveServerLaunchCommand({ + workspaceManager, + sessionId: 'zoo-game-work2-server', + environment: 'production' + }); + expect(result.command).toBe('hytopia start'); + expect(result.usedGameMode).toBeNull(); + }); +}); diff --git a/tests/unit/taskRecordService.evidence.test.js b/tests/unit/taskRecordService.evidence.test.js new file mode 100644 index 00000000..83653371 --- /dev/null +++ b/tests/unit/taskRecordService.evidence.test.js @@ -0,0 +1,80 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { TaskRecordService, normalizeEvidence } = require('../../server/taskRecordService'); + +const makeService = () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'orchestrator-evidence-')); + return new TaskRecordService({ filePath: path.join(tmp, 'task-records.json') }); +}; + +describe('task record evidence field', () => { + test('upsert stores a full normalized evidence object', async () => { + const svc = makeService(); + const rec = await svc.upsert('pr:me/repo#1', { + evidence: { + summary: 'Added spawn system', + tests: { ran: true, command: 'npm test', passed: 47, failed: 0, output: 'ok', at: '2026-07-15T00:00:00Z' }, + appRun: { ran: true, method: 'puppeteer', url: 'http://localhost:5555', notes: 'no console errors' }, + media: [{ type: 'image', path: '.agent-evidence/shot.png', caption: 'spawn menu' }], + data: [{ metric: 'dps', before: 120, after: 90, note: 'autoplay 3 runs' }], + reviews: [{ role: 'Security', agentId: 'Codex', model: 'gpt-5.5', effort: 'HIGH', verdict: 'approved', findings: 2, fixed: 2 }], + standards: ['CLAUDE.md', 'CLAUDE.md', 'docs/STANDARDS.md'], + handoff: { notes: 'branch is rebased; only polish left' }, + diffStats: { files: 12, additions: 340, deletions: 80 } + } + }); + + const ev = rec.evidence; + expect(ev.schema).toBe(1); + expect(typeof ev.updatedAt).toBe('string'); + expect(ev.tests).toEqual({ ran: true, command: 'npm test', passed: 47, failed: 0, output: 'ok', at: '2026-07-15T00:00:00.000Z' }); + expect(ev.appRun.method).toBe('puppeteer'); + expect(ev.media).toHaveLength(1); + expect(ev.data[0]).toEqual({ metric: 'dps', before: 120, after: 90, note: 'autoplay 3 runs' }); + expect(ev.reviews[0]).toMatchObject({ role: 'security', agentId: 'codex', model: 'gpt-5.5', effort: 'high', verdict: 'approved', findings: 2, fixed: 2 }); + expect(ev.standards).toEqual(['CLAUDE.md', 'docs/STANDARDS.md']); + expect(ev.diffStats).toEqual({ files: 12, additions: 340, deletions: 80 }); + }); + + test('evidence: null clears the field', async () => { + const svc = makeService(); + await svc.upsert('task:x', { evidence: { summary: 'something' } }); + const cleared = await svc.upsert('task:x', { evidence: null }); + expect(cleared.evidence).toBeUndefined(); + }); + + test('garbage evidence entries are dropped, not stored', () => { + const ev = normalizeEvidence({ + media: [{ type: 'image' }, 'nope', { type: 'weird', path: 'a.png' }], + data: [{ note: 'no metric' }], + reviews: [{}, { verdict: 'not-a-verdict', role: 'general' }], + diffStats: { files: -3, additions: 'NaN' } + }); + + expect(ev.media).toEqual([{ type: 'other', path: 'a.png' }]); + expect(ev.data).toBeUndefined(); + // review with invalid verdict keeps role but drops the bad verdict + expect(ev.reviews).toEqual([{ role: 'general' }]); + expect(ev.diffStats).toBeUndefined(); + }); + + test('normalizeEvidence returns null for empty/invalid input', () => { + expect(normalizeEvidence(null)).toBeNull(); + expect(normalizeEvidence('text')).toBeNull(); + expect(normalizeEvidence({})).toBeNull(); + expect(normalizeEvidence({ media: [], reviews: [] })).toBeNull(); + }); + + test('long strings are capped', () => { + const ev = normalizeEvidence({ + summary: 'x'.repeat(5000), + tests: { output: 'y'.repeat(10000) }, + handoff: { notes: 'z'.repeat(10000) } + }); + expect(ev.summary.length).toBe(2000); + expect(ev.tests.output.length).toBe(4000); + expect(ev.handoff.notes.length).toBe(4000); + }); +}); diff --git a/tests/unit/visibilityPresetService.test.js b/tests/unit/visibilityPresetService.test.js new file mode 100644 index 00000000..ebcbcdb3 --- /dev/null +++ b/tests/unit/visibilityPresetService.test.js @@ -0,0 +1,47 @@ +const { listPresets, applyPreset, buildPresetVisibility } = require('../../server/visibilityPresetService'); + +describe('visibilityPresetService', () => { + test('lists both presets', () => { + const presets = listPresets(); + expect(presets.map(p => p.id).sort()).toEqual(['power', 'simple']); + }); + + test('simple preset mirrors shipped defaults (workflow layer hidden)', () => { + const vis = buildPresetVisibility('simple'); + expect(vis.header.workflowMode).toBe(false); + expect(vis.header.prs).toBe(false); + expect(vis.processBanner).toBe(false); + }); + + test('power preset enables the workflow layer without touching unrelated flags', () => { + const vis = buildPresetVisibility('power'); + expect(vis.header.workflowMode).toBe(true); + expect(vis.header.tierFilters).toBe(true); + expect(vis.header.queue).toBe(true); + expect(vis.dashboard.processSection).toBe(true); + expect(vis.commander.advice).toBe(true); + // intentHints stays opt-in (may call a model API) + expect(vis.terminal.intentHints).toBe(false); + // unrelated defaults survive the merge + expect(vis.terminal.removeWorktree).toBe(true); + }); + + test('applyPreset persists visibility + preset name via userSettingsService', () => { + const saved = {}; + const fakeSettings = { + getAllSettings: () => ({ global: { ui: { existing: 'kept' } } }), + updateGlobalSettings: (global) => { saved.global = global; return true; } + }; + + const result = applyPreset(fakeSettings, 'power'); + expect(result.preset).toBe('power'); + expect(saved.global.ui.visibilityPreset).toBe('power'); + expect(saved.global.ui.visibility.header.workflowMode).toBe(true); + expect(saved.global.ui.existing).toBe('kept'); + }); + + test('rejects unknown presets', () => { + expect(() => applyPreset({ getAllSettings: () => ({}), updateGlobalSettings: () => true }, 'nope')) + .toThrow(/Unknown visibility preset/); + }); +}); diff --git a/thoughts/ledgers/CONTINUITY_CLAUDE-orchestrator.md b/thoughts/ledgers/CONTINUITY_CLAUDE-orchestrator.md index 541f3ce6..098a5528 100644 --- a/thoughts/ledgers/CONTINUITY_CLAUDE-orchestrator.md +++ b/thoughts/ledgers/CONTINUITY_CLAUDE-orchestrator.md @@ -1,29 +1,31 @@ --- -date: 2026-01-11T15:18:00Z -project: claude-orchestrator +date: 2026-07-18T02:15:00Z +project: agent-workspace (formerly claude-orchestrator) --- ## Goal -Transform Claude Orchestrator into a unified AI development command center with session continuity, greenfield project support, and automated testing. +Open-PR sweep (2026-07-18): review all 17 open PRs, fix issues on their branches, merge approved ones into a test branch, deploy for user testing. ## Current State -- Analyzed entire codebase with sub-agents -- Created comprehensive IMPROVEMENT_ROADMAP.md (PR #74) -- Identified key pain points: greenfield workflow, config system, logging spam -- Set up continuous-claude-lite for session persistence +- All 17 open PRs reviewed via 14-scout swarm; fixes pushed to every approved branch (see PR comments for verdicts). +- Integration branch `integration/pr-test-2026-07-18` (pushed to origin) = origin/main v0.1.22 + 9 merged branches: #1022 (+3 fix commits incl. evidence worktreePath security fix), #1013, #1014, #1016, #1018, #1020, #993, #997, #998 — all with review fixes. 696/696 unit tests green on the merged tree. +- Deployed to ~/GitHub/tools/automation/claude-orchestrator/master on ports 3000 (server) / 2080 (client) / 7655 (diff-viewer). NOTE: shell env inherited from the dev orchestrator overrides .env ports — must start with explicit `ORCHESTRATOR_PORT=3000 CLIENT_PORT=2080 DIFF_VIEWER_PORT=7655 npm start`. +- master/'s pre-existing dirty package-locks preserved on branch `backup/dirty-worktree-2026-07-18`. +- User's live dev instance: ~/GitHub/tools/automation/claude-orchestrator/claude-orchestrator-dev on 4000/2081/7656 (main @ v0.1.20, behind). The ~/GitHub/tools/automation/agent-workspace/{master,agent-workspace-dev} copies are an unused half-migration (no .env/node_modules). + +## Verdicts (details in PR comments) +- MERGE (fixed): 1013, 1014, 1016, 1018, 1020, 997, 998, 1022, 993 +- CLOSE: 788, 791, 796, 831, 843, 992; 1015 superseded by 1014 (its test cherry-picked into 1014) +- REBASE_AND_RECONSIDER: 804 (paste-review-back-to-source-terminal feature is novel; architecture superseded by agentSpawnHelper) ## Next Steps -1. Set up automated testing (unit + e2e with Playwright) -2. Fix cascaded config merging bug -3. Implement port registry system -4. Build greenfield project wizard -5. Integrate continuity service to read ledgers in UI +1. User tests the integration instance; then merge approved PRs individually into main (order: 1022 first, then small ones; 1014 not 1015). +2. Close superseded/stale PRs (user's call). +3. Consider re-implementing #804's paste-back feature fresh; consider deleting the unused agent-workspace/ dir pair or finishing that migration. ## Key Decisions -- Each worktree = independent project with own ledger -- Use continuous-claude-lite for session persistence -- Commander Claude will use tool-calling for orchestration -- Playwright for e2e testing (better than Puppeteer) - -## Open PRs -- #74 - Improvement Roadmap (analysis branch) +- 1014 chosen over 1015 (harness-verified: never uses bare worktree key when repo derivable). +- 1016 filter narrowed to idle-hover motion only (clicks/drags/scroll preserved). +- 1018 close (✕) confirms before stopping a running Commander; in multi-commander it stops the active instance via cmdBody(). +- 1020 live-model detection switched from substring regex to JSONL line parsing (assistant message.model only) + TTL cache. +- terminal.startServer default flipped to false on the 1022 branch (Power preset enables it). diff --git a/user-settings.default.json b/user-settings.default.json index 438f1435..77b49ed5 100644 --- a/user-settings.default.json +++ b/user-settings.default.json @@ -157,7 +157,7 @@ "forceKill": true, "launchSettings": false, "serverLaunchMenu": false, - "startServer": true + "startServer": false }, "dashboard": { "processBanner": false, @@ -203,7 +203,8 @@ "startClaude": false, "advice": false, "sessions": true, - "modeSelect": false + "modeSelect": false, + "tabs": false } }, "desktop": { @@ -288,4 +289,4 @@ } }, "perTerminal": {} -} +} \ No newline at end of file