diff --git a/README.md b/README.md index 70dd70e9e..f1da6eeb9 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,10 @@ One command. Walk away. Come back to a built project with clean git history. ## What's New in v2.71 -### MCP Secure Env Collect +### External Tooling -- **Secure credential collection over MCP** — the new `secure_env_collect` tool uses MCP form elicitation to collect secrets (API keys, tokens) from external clients without exposing values in tool output. Masks input in interactive mode. -- **Hardened elicitation schema** — MCP elicitation schema handling is stricter, with proper validation and fallback for providers that don't support forms. - -### MCP Reliability - -- **Stream ordering preserved** — MCP tool output now renders in the correct order, fixing interleaved output in Claude Code and other MCP clients. -- **isError flag propagation** — workflow tool execution failures now correctly return `isError: true`, so MCP clients can distinguish success from failure. +- **External MCP tool configs** — SF can connect to project-local MCP tool servers for third-party services and local integrations. +- **Stream ordering preserved** — external tool output now renders in the correct order, including MCP tool calls surfaced by model/runtime adapters. - **Multi-round discuss questions** — new-project discuss phase supports multi-round questioning with structured question gates. ### Model Selection Hardening @@ -49,7 +44,7 @@ One command. Walk away. Come back to a built project with clean git history. ### Auto-Mode Resilience -- **Credential cooldown recovery** — auto-mode survives transient 429 rate-limit responses with structured cooldown errors and a bounded retry budget. +- **Credential cooldown recovery** — autonomous mode survives transient 429 rate-limit responses with structured cooldown errors and a bounded retry budget. - **Fire-and-forget auto start** — auto start is detached from active turns to prevent blocking. - **Scoped forensics** — stuck-loop forensics are now scoped to auto sessions only, preventing false positives in interactive use. @@ -85,10 +80,9 @@ See the full [Changelog](./CHANGELOG.md) for details on every release.
Previous highlights (v2.70 and earlier) -- **Full workflow over MCP (v2.68)** — slice replanning, milestone management, slice completion, task completion, and core planning tools exposed over MCP -- **Transport-gated MCP (v2.68)** — workflow tool availability adapts to provider transport capabilities automatically +- **External MCP integrations (v2.68)** — project-local MCP configs connect SF to external tools; SF workflow is no longer exposed as MCP - **Contextual tips system (v2.68)** — TUI and web terminal surface contextual tips based on workflow state -- **Ask user questions over MCP (v2.70)** — interactive questions exposed via elicitation for external integrations +- **Structured questions** — interactive prompts stay inside SF's direct runtime flow - **Tiered Context Injection (M005)** — relevance-scoped context with 65%+ token reduction - **Resilient transient error recovery** — defers to Core RetryHandler and fixes cmdCtx race conditions - **Anthropic subscription routing** — auto-routed through Claude Code CLI provider with proper display names @@ -96,7 +90,7 @@ See the full [Changelog](./CHANGELOG.md) for details on every release. - **Discussion gate enforcement** — mechanical enforcement with fail-closed behavior - **Slice-level parallelism** — dependency-aware parallel dispatch within a milestone - **Persistent notification panel** — TUI overlay, widget, and web API for real-time notifications -- **MCP server** — 6 read-only project state tools for external integrations, auto-wrapup guard, and question dedup +- **MCP client integrations** — external tool servers can be discovered and used from SF sessions - **Ollama extension** — first-class local LLM support via Ollama, with dynamic routing enabled by default - **Discord bot & daemon** — dedicated daemon package, Discord bot, and headless text mode with tool calls - **Capability-aware model routing (ADR-004)** — capability scoring, `before_model_select` hook, and task metadata extraction @@ -104,7 +98,7 @@ See the full [Changelog](./CHANGELOG.md) for details on every release. - **`/sf parallel watch`** — native TUI overlay for real-time worker monitoring - **Codebase map** — automatic codebase map injection for fresh agent contexts - **`--resume` flag** — resume previous sessions from the CLI -- **Concurrent invocation guard** — prevents overlapping auto-mode runs +- **Concurrent invocation guard** — prevents overlapping autonomous mode runs - **VS Code integration** — status bar, file decorations, bash terminal, session tree, conversation history, and code lens - **Skills overhaul** — 30+ skill packs covering major frameworks, databases, and cloud platforms - **Single-writer state engine** — disciplined state transitions with machine guards and TOCTOU hardening @@ -237,7 +231,7 @@ This is what makes SF different. Run it, walk away, come back to built software. /sf autonomous ``` -Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, autonomous mode reads disk state again and dispatches the next unit. `/sf auto` remains supported as a short alias. +Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session, injects a focused prompt with all relevant context pre-inlined, and lets the LLM execute. When the LLM finishes, autonomous mode reads disk state again and dispatches the next unit. Legacy `/sf auto` remains accepted only for compatibility; new prompts and docs use `/sf autonomous`. **What happens under the hood:** @@ -251,7 +245,7 @@ Autonomous mode is a state machine driven by files on disk. It reads `.sf/STATE. 5. **Provider error recovery** — Transient provider errors (rate limits, 500/503 server errors, overloaded) auto-resume after a delay. Permanent errors (auth, billing) pause for manual review. The model fallback chain retries transient network errors before switching models. -6. **Stuck detection** — A sliding-window detector identifies repeated dispatch patterns (including multi-unit cycles). On detection, it retries once with a deep diagnostic. If it fails again, auto mode stops with the exact file it expected. +6. **Stuck detection** — A sliding-window detector identifies repeated dispatch patterns (including multi-unit cycles). On detection, it retries once with a deep diagnostic. If it fails again, autonomous mode stops with the exact file it expected. 7. **Timeout supervision** — Soft timeout warns the LLM to wrap up. Idle watchdog detects stalls. Hard timeout pauses autonomous mode. Recovery steering nudges the LLM to finish durable output before giving up. @@ -317,7 +311,7 @@ SF opens an interactive agent session. From there, you have two ways to work: **`/sf` — step mode.** Type `/sf` and SF executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as autonomous mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step. -**`/sf autonomous` — autonomous mode.** Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. `/sf auto` is an alias. +**`/sf autonomous` — autonomous mode.** Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. ### Two terminals, one project @@ -377,20 +371,19 @@ On first run, SF launches a branded setup wizard that walks you through LLM prov | `/sf` | Step mode — executes one unit at a time, pauses between each | | `/sf next` | Explicit step mode (same as bare `/sf`) | | `/sf autonomous` | Autonomous mode — researches, plans, executes, commits, repeats | -| `/sf auto` | Alias for `/sf autonomous` | | `/sf quick` | Execute a quick task with SF guarantees, skip planning overhead | | `/sf stop` | Stop autonomous mode gracefully | | `/sf steer` | Hard-steer plan documents during execution | | `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) | | `/sf rethink` | Conversational project reorganization | -| `/sf mcp` | MCP server status and connectivity | +| `/sf mcp` | External MCP server status and connectivity | | `/sf status` | Progress dashboard | | `/sf queue` | Queue future milestones (safe during autonomous mode) | | `/sf prefs` | Model selection, timeouts, budget ceiling | | `/sf migrate` | Migrate a v1 `.planning` directory to `.sf` format | | `/sf help` | Categorized command reference for all SF subcommands | | `/sf mode` | Switch workflow mode (solo/team) with coordinated defaults | -| `/sf forensics` | Full-access SF debugger for auto-mode failure investigation | +| `/sf forensics` | Full-access SF debugger for autonomous mode failure investigation | | `/sf cleanup` | Archive phase directories from completed milestones | | `/sf doctor` | Runtime health checks — issues surface across widget, visualizer, and reports | | `/sf keys` | API key manager — list, add, remove, test, rotate, doctor | @@ -536,7 +529,7 @@ auto_report: true | `verification_commands`| Array of shell commands to run after task execution (e.g., `["npm run lint", "npm run test"]`) | | `verification_auto_fix`| Auto-retry on verification failures (default: true) | | `verification_max_retries` | Max retries for verification failures (default: 2) | -| `phases.require_slice_discussion` | Pause auto-mode before each slice for human discussion review | +| `phases.require_slice_discussion` | Pause autonomous mode before each slice for human discussion review | | `auto_report` | Auto-generate HTML reports after milestone completion (default: true) | ### Agent Instructions @@ -547,7 +540,7 @@ Place an `AGENTS.md` file in any directory to provide persistent behavioral guid ### Debug Mode -Start SF with `sf --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting auto-mode issues. +Start SF with `sf --debug` to enable structured JSONL diagnostic logging. Debug logs capture dispatch decisions, state transitions, and timing data for troubleshooting autonomous mode issues. ### Token Optimization @@ -622,9 +615,9 @@ The best practice for working in teams is to ensure unique milestone names acros ```bash # ── SF: Runtime / Ephemeral (per-developer, per-session) ────────────────── -# Crash detection sentinel — PID lock, written per auto-mode session +# Crash detection sentinel — PID lock, written per autonomous mode session .sf/auto.lock -# Auto-mode dispatch tracker — prevents re-running completed units (includes archived per-milestone files) +# Autonomous mode dispatch tracker — prevents re-running completed units (includes archived per-milestone files) .sf/completed-units*.json # State manifest — workflow state for recovery .sf/state-manifest.json @@ -735,7 +728,7 @@ Anthropic, Anthropic (Vertex AI), OpenAI, Google (Gemini), OpenRouter, GitHub Co ### OAuth / Max Plans -If you have a **Claude Max**, **Codex**, or **GitHub Copilot** subscription, you can use those directly — Pi handles the OAuth flow. No API key needed. +If you have a **Claude Max**, **Codex**, or **GitHub Copilot** subscription, SF can use the corresponding local authenticated runtime/provider adapter directly. Claude Code and Codex are not project MCP dependencies; they are model/runtime routes. Gemini can also route through the Gemini CLI core path where configured. > **⚠️ Important:** Using OAuth tokens from subscription plans outside their native applications may violate the provider's Terms of Service. In particular: > diff --git a/docker/README.md b/docker/README.md index 2e12a33b6..c0ea78d49 100644 --- a/docker/README.md +++ b/docker/README.md @@ -37,7 +37,7 @@ docker sandbox create --template ./docker --name sf-sandbox docker sandbox exec -it sf-sandbox bash # Inside the sandbox, run SF -sf auto "implement the feature described in issue #42" +sf autonomous "implement the feature described in issue #42" ``` ### Option B: Docker Compose @@ -56,7 +56,7 @@ docker compose -f docker/docker-compose.yaml up -d docker exec -it sf-sandbox bash # 4. Run SF inside the container -sf auto "implement the feature described in issue #42" +sf autonomous "implement the feature described in issue #42" ``` ## UID/GID Remapping @@ -89,7 +89,7 @@ SF's recommended workflow uses two terminals — one for auto mode, one for inte ```bash # Terminal 1: auto mode docker sandbox exec -it sf-sandbox bash -sf auto "your task description" +sf autonomous "your task description" # Terminal 2: discuss / monitor docker sandbox exec -it sf-sandbox bash diff --git a/docs/README.md b/docs/README.md index cc0829aec..adb57cbc4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ Simplified Chinese translation: [`zh-CN/`](./zh-CN/). | [Getting Started](./user-docs/getting-started.md) | Installation, first run, and basic usage | | [Autonomous Mode](./user-docs/auto-mode.md) | How autonomous execution works — the state machine, crash recovery, and steering | | [Commands Reference](./user-docs/commands.md) | All commands, keyboard shortcuts, and CLI flags | -| [Remote Questions](./user-docs/remote-questions.md) | Discord and Slack integration for headless auto-mode | +| [Remote Questions](./user-docs/remote-questions.md) | Discord and Slack integration for headless autonomous-mode | | [Configuration](./user-docs/configuration.md) | Preferences, model selection, git settings, and token profiles | | [Provider Setup](./user-docs/providers.md) | Step-by-step setup for OpenRouter, Ollama, LM Studio, vLLM, and all supported providers | | [Custom Models](./user-docs/custom-models.md) | Advanced model configuration — models.json schema, compat flags, overrides | diff --git a/docs/RELIABILITY.md b/docs/RELIABILITY.md index b3420578a..563b9a887 100644 --- a/docs/RELIABILITY.md +++ b/docs/RELIABILITY.md @@ -48,7 +48,7 @@ ## Restart Loop (headless daemon mode) -`sf headless auto --max-restarts 3` applies exponential backoff: 5 s → 10 s → 30 s (cap). After exhausting restarts the parent exits with code 1. Each restart resumes via crash recovery above. +`sf headless autonomous --max-restarts 3` applies exponential backoff: 5 s → 10 s → 30 s (cap). After exhausting restarts the parent exits with code 1. Each restart resumes via crash recovery above. ## Observability diff --git a/docs/user-docs/auto-mode.md b/docs/user-docs/auto-mode.md index 693b06181..6894f9f48 100644 --- a/docs/user-docs/auto-mode.md +++ b/docs/user-docs/auto-mode.md @@ -1,6 +1,6 @@ # Autonomous Mode -Autonomous mode is SF's product-development execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history. `/sf auto` remains supported as a short alias. +Autonomous mode is SF's product-development execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history. `/sf autonomous` remains supported as a short alias. ## How It Works @@ -61,7 +61,7 @@ When your project has independent milestones, you can run them simultaneously. E A lock file tracks the current unit. If the session dies, the next `/sf autonomous` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. -**Headless auto-restart (v2.26):** When running `sf headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution. +**Headless auto-restart (v2.26):** When running `sf headless autonomous`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Configure with `--max-restarts N`. SIGINT/SIGTERM bypasses restart. Combined with crash recovery, this enables true overnight "run until done" execution. ### Provider Error Recovery @@ -89,13 +89,13 @@ Commits are generated from task summaries — not generic "complete task" messag ### Stuck Detection (v2.39) -SF uses a sliding-window analysis to detect stuck loops. Instead of a simple "same unit dispatched twice" counter, the detector examines recent dispatch history for repeated patterns — catching cycles like A→B→A→B as well as single-unit repeats. On detection, SF retries once with a deep diagnostic prompt. If it fails again, auto mode stops with the exact file it expected, so you can intervene. +SF uses a sliding-window analysis to detect stuck loops. Instead of a simple "same unit dispatched twice" counter, the detector examines recent dispatch history for repeated patterns — catching cycles like A→B→A→B as well as single-unit repeats. On detection, SF retries once with a deep diagnostic prompt. If it fails again, autonomous mode stops with the exact file it expected, so you can intervene. The sliding-window approach reduces false positives on legitimate retries (e.g., verification failures that self-correct) while catching genuine stuck loops faster. ### Post-Mortem Investigation (v2.40) -`/sf forensics` is a full-access SF debugger for post-mortem analysis of auto-mode failures. It provides: +`/sf forensics` is a full-access SF debugger for post-mortem analysis of autonomous mode failures. It provides: - **Anomaly detection** — structured identification of stuck loops, cost spikes, timeouts, missing artifacts, and crashes with severity levels - **Unit traces** — last 10 unit executions with error details and execution times @@ -117,7 +117,7 @@ Three timeout tiers prevent runaway sessions: |---------|---------|----------| | Soft | 20 min | Warns the LLM to wrap up | | Idle | 10 min | Detects stalls, intervenes | -| Hard | 30 min | Pauses auto mode | +| Hard | 30 min | Pauses autonomous mode | Recovery steering nudges the LLM to finish durable output before timing out. Configure in preferences: @@ -130,7 +130,7 @@ auto_supervisor: ### Cost Tracking -Every unit's token usage and cost is captured, broken down by phase, slice, and model. The dashboard shows running totals and projections. Budget ceilings can pause auto mode before overspending. +Every unit's token usage and cost is captured, broken down by phase, slice, and model. The dashboard shows running totals and projections. Budget ceilings can pause autonomous mode before overspending. See [Cost Management](./cost-management.md). @@ -160,7 +160,7 @@ For projects where you want human review before each slice begins: require_slice_discussion: true ``` -Auto-mode pauses before each slice, presenting the slice context for discussion. After you confirm, execution continues. Useful for high-stakes projects where you want to review the plan before the agent builds. +Autonomous mode pauses before each slice, presenting the slice context for discussion. After you confirm, execution continues. Useful for high-stakes projects where you want to review the plan before the agent builds. ### HTML Reports (v2.26) @@ -174,7 +174,7 @@ Generate manually anytime with `/sf export --html`, or generate reports for all ### Failure Recovery (v2.28) -v2.28 hardens auto-mode reliability with multiple safeguards: atomic file writes prevent corruption on crash, OAuth fetch timeouts (30s) prevent indefinite hangs, RPC subprocess exit is detected and reported, and blob garbage collection prevents unbounded disk growth. Combined with the existing crash recovery and headless auto-restart, auto-mode is designed for true "fire and forget" overnight execution. +v2.28 hardens autonomous mode reliability with multiple safeguards: atomic file writes prevent corruption on crash, OAuth fetch timeouts (30s) prevent indefinite hangs, RPC subprocess exit is detected and reported, and blob garbage collection prevents unbounded disk growth. Combined with the existing crash recovery and headless autonomous-restart, autonomous mode is designed for true "fire and forget" overnight execution. ### Pipeline Architecture (v2.40) @@ -196,7 +196,7 @@ Doctor issues (from `/sf doctor`) now surface in real time across three places: - **Workflow visualizer** — issues shown in the status panel - **HTML reports** — health section with all issues at report generation time -Issues are classified by severity: `error` (blocks auto-mode), `warning` (non-blocking), and `info` (advisory). Auto-mode checks health at dispatch time and can pause on critical issues. +Issues are classified by severity: `error` (blocks autonomous mode), `warning` (non-blocking), and `info` (advisory). Autonomous mode checks health at dispatch time and can pause on critical issues. ### Skill Activation in Prompts (v2.39) @@ -216,8 +216,6 @@ See [Configuration](./configuration.md) for skill routing preferences. /sf autonomous ``` -`/sf auto` is equivalent to `/sf autonomous`. - ### Pause Press **Escape**. The conversation is preserved. You can interact with the agent, inspect state, or resume. @@ -236,7 +234,7 @@ Autonomous mode reads disk state and picks up where it left off. /sf stop ``` -Stops auto mode gracefully. Can be run from a different terminal. +Stops autonomous mode gracefully. Can be run from a different terminal. ### Steer @@ -288,7 +286,7 @@ See [Token Optimization](./token-optimization.md) for details. ## Dynamic Model Routing -When enabled, auto-mode automatically selects cheaper models for simple units (slice completion, UAT) and reserves expensive models for complex work (replanning, architectural tasks). See [Dynamic Model Routing](./dynamic-model-routing.md). +When enabled, autonomous mode automatically selects cheaper models for simple units (slice completion, UAT) and reserves expensive models for complex work (replanning, architectural tasks). See [Dynamic Model Routing](./dynamic-model-routing.md). ## Reactive Task Execution (v2.38) diff --git a/docs/user-docs/captures-triage.md b/docs/user-docs/captures-triage.md index 04c1331c7..c1ff34369 100644 --- a/docs/user-docs/captures-triage.md +++ b/docs/user-docs/captures-triage.md @@ -2,11 +2,11 @@ *Introduced in v2.19.0* -Captures let you fire-and-forget thoughts during auto-mode execution. Instead of pausing auto-mode to steer, you can capture ideas, bugs, or scope changes and let SF triage them at natural seams between tasks. +Captures let you fire-and-forget thoughts during autonomous mode execution. Instead of pausing autonomous mode to steer, you can capture ideas, bugs, or scope changes and let SF triage them at natural seams between tasks. ## Quick Start -While auto-mode is running (or any time): +While autonomous mode is running (or any time): ``` /sf capture "add rate limiting to the API endpoints" @@ -27,7 +27,7 @@ capture → triage → confirm → resolve → resume 2. **Triage** — at natural seams between tasks (in `handleAgentEnd`), SF detects pending captures and classifies them 3. **Confirm** — the user is shown the proposed resolution and confirms or adjusts 4. **Resolve** — the resolution is applied (task injection, replan trigger, deferral, etc.) -5. **Resume** — auto-mode continues +5. **Resume** — autonomous mode continues ### Classification Types @@ -43,7 +43,7 @@ Each capture is classified into one of five types: ### Automatic Triage -Triage fires automatically between tasks during auto-mode. The triage prompt receives: +Triage fires automatically between tasks during autonomous mode. The triage prompt receives: - All pending captures - The current slice plan - The active roadmap @@ -62,7 +62,7 @@ This is useful when you've accumulated several captures and want to process them ## Dashboard Integration -The progress widget shows a pending capture count badge when captures are waiting for triage. This is visible in both the `Ctrl+Alt+G` dashboard and the auto-mode progress widget. +The progress widget shows a pending capture count badge when captures are waiting for triage. This is visible in both the `Ctrl+Alt+G` dashboard and the autonomous mode progress widget. ## Context Injection @@ -72,7 +72,7 @@ Capture context is automatically injected into: ## Worktree Awareness -Captures always resolve to the **original project root's** `.sf/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the auto-mode session running in a worktree. +Captures always resolve to the **original project root's** `.sf/CAPTURES.md`, not the worktree's local copy. This ensures captures from a steering terminal are visible to the autonomous mode session running in a worktree. ## Commands diff --git a/docs/user-docs/commands.md b/docs/user-docs/commands.md index 4a224843a..a54645104 100644 --- a/docs/user-docs/commands.md +++ b/docs/user-docs/commands.md @@ -7,20 +7,19 @@ | `/sf` | Step mode — execute one unit at a time, pause between each | | `/sf next` | Explicit step mode (same as `/sf`) | | `/sf autonomous` | Autonomous product loop — research, plan, execute, commit, repeat | -| `/sf auto` | Alias for `/sf autonomous` | | `/sf quick` | Execute a quick task with SF guarantees (atomic commits, state tracking) without full planning overhead | | `/sf stop` | Stop autonomous mode gracefully | | `/sf pause` | Pause autonomous mode (preserves state, `/sf autonomous` to resume) | | `/sf steer` | Hard-steer plan documents during execution | -| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) | +| `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) | | `/sf status` | Progress dashboard | | `/sf widget` | Cycle dashboard widget: full / small / min / off | -| `/sf queue` | Queue and reorder future milestones (safe during auto mode) | -| `/sf capture` | Fire-and-forget thought capture (works during auto mode) | +| `/sf queue` | Queue and reorder future milestones (safe during autonomous mode) | +| `/sf capture` | Fire-and-forget thought capture (works during autonomous mode) | | `/sf triage` | Manually trigger triage of pending captures | | `/sf dispatch` | Dispatch a specific phase directly (research, plan, execute, complete, reassess, uat, replan) | | `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | -| `/sf forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for auto-mode failures | +| `/sf forensics` | Full-access SF debugger — structured anomaly detection, unit traces, and LLM-guided root-cause analysis for autonomous mode failures | | `/sf cleanup` | Clean up SF state files and stale worktrees | | `/sf visualize` | Open workflow visualizer (progress, deps, metrics, timeline) | | `/sf export --html` | Generate self-contained HTML report for current or completed milestone | @@ -31,7 +30,7 @@ | `/sf rate` | Rate last unit's model tier (over/ok/under) — improves adaptive routing | | `/sf changelog` | Show categorized release notes | | `/sf logs` | Browse activity logs, debug logs, and metrics | -| `/sf remote` | Control remote auto-mode | +| `/sf remote` | Control remote autonomous mode | | `/sf help` | Categorized command reference with descriptions for all SF subcommands | ## Configuration & Diagnostics @@ -59,7 +58,7 @@ | Command | Description | |---------|-------------| | `/sf new-milestone` | Create a new milestone | -| `/sf skip` | Prevent a unit from auto-mode dispatch | +| `/sf skip` | Prevent a unit from autonomous mode dispatch | | `/sf undo` | Revert last completed unit | | `/sf undo-task` | Reset a specific task's completion state (DB + markdown) | | `/sf reset-slice` | Reset a slice and all its tasks (DB + markdown) | @@ -94,13 +93,13 @@ See [Parallel Orchestration](./parallel-orchestration.md) for full documentation | Command | Description | |---------|-------------| | `/sf workflow new` | Create a new workflow definition (via skill) | -| `/sf workflow run ` | Create a run and start auto-mode | +| `/sf workflow run ` | Create a run and start autonomous mode | | `/sf workflow list` | List workflow runs | | `/sf workflow validate ` | Validate a workflow definition YAML | -| `/sf workflow pause` | Pause custom workflow auto-mode | -| `/sf workflow resume` | Resume paused custom workflow auto-mode | +| `/sf workflow pause` | Pause custom workflow autonomous mode | +| `/sf workflow resume` | Resume paused custom workflow autonomous mode | -`/sf autonomous` is the product-development loop that chooses the next useful unit from project state. `/sf start` is guided workflow kickoff and may ask clarifying questions. `/sf workflow run` executes an explicit YAML workflow definition. `/sf auto` remains supported as shorthand for `/sf autonomous`. +`/sf autonomous` is the product-development loop that chooses the next useful unit from project state. `/sf start` is guided workflow kickoff and may ask clarifying questions. `/sf workflow run` executes an explicit YAML workflow definition. `/sf autonomous` remains supported as shorthand for `/sf autonomous`. ## Extensions @@ -157,7 +156,7 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a | `Ctrl+Alt+V` | Toggle voice transcription | | `Ctrl+Alt+B` | Show background shell processes | | `Ctrl+V` / `Alt+V` | Paste image from clipboard (screenshot → vision input) | -| `Escape` | Pause auto mode (preserves conversation) | +| `Escape` | Pause autonomous mode (preserves conversation) | > **Note:** In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBrains IDEs), slash-command fallbacks are shown instead of `Ctrl+Alt` shortcuts. > @@ -192,7 +191,7 @@ Enable with `github.enabled: true` in preferences. Requires `gh` CLI installed a `sf headless` runs `/sf` commands without a TUI — designed for CI, cron jobs, and scripted automation. It spawns a child process in RPC mode, auto-responds to interactive prompts, detects completion, and exits with meaningful exit codes. ```bash -# Run auto mode (default) +# Run autonomous mode (default) sf headless # Run a single unit @@ -202,12 +201,12 @@ sf headless next sf headless query # With timeout for CI -sf headless --timeout 600000 auto +sf headless --timeout 600000 autonomous # Force a specific phase sf headless dispatch plan -# Create a new milestone from a context file and start auto mode +# Create a new milestone from a context file and start autonomous mode sf headless new-milestone --context brief.md --auto # Create a milestone from inline text @@ -225,7 +224,7 @@ echo "Build a CLI tool" | sf headless new-milestone --context - | `--model ID` | Override the model for the headless session | | `--context ` | Context file for `new-milestone` (use `-` for stdin) | | `--context-text ` | Inline context text for `new-milestone` | -| `--auto` | Chain into auto-mode after milestone creation | +| `--auto` | Chain into autonomous mode after milestone creation | **Exit codes:** `0` = complete, `1` = error or timeout, `2` = blocked. @@ -271,16 +270,10 @@ sf headless query | jq '.cost.total' } ``` -## MCP Server Mode +## MCP Integrations -`sf --mode mcp` runs SF as a [Model Context Protocol](https://modelcontextprotocol.io) server over stdin/stdout. This exposes all SF tools (read, write, edit, bash, etc.) to external AI clients — Claude Desktop, VS Code Copilot, and any MCP-compatible host. - -```bash -# Start SF as an MCP server -sf --mode mcp -``` - -The server registers all tools from the agent session and maps MCP `tools/list` and `tools/call` requests to SF tool definitions. It runs until the transport closes. +`/sf mcp` shows configured external MCP tool servers. SF does not expose its own +workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`. ## In-Session Update diff --git a/docs/user-docs/configuration.md b/docs/user-docs/configuration.md index b8c6572ee..4eb488690 100644 --- a/docs/user-docs/configuration.md +++ b/docs/user-docs/configuration.md @@ -149,7 +149,7 @@ Recommended verification order: - Use absolute paths for local executables and scripts when possible. - For `stdio` servers, prefer setting required environment variables directly in the MCP config instead of relying on an interactive shell profile. -- SF and `sf-mcp-server` both hydrate supported model and tool keys saved in `~/.sf/agent/auth.json`, so MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials. +- SF hydrates supported model and tool keys saved in `~/.sf/agent/auth.json`, so external MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials. - If a server is team-shared and safe to commit, `.mcp.json` is usually the better home. - If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.sf/mcp.json`. @@ -259,7 +259,7 @@ Values: `budget`, `balanced` (default), `quality` ### `phases` -Fine-grained control over which phases run in auto mode: +Fine-grained control over which phases run in autonomous mode: ```yaml phases: @@ -267,7 +267,7 @@ phases: skip_reassess: false # skip roadmap reassessment after each slice skip_slice_research: true # skip per-slice research reassess_after_slice: true # enable roadmap reassessment after each slice (required for reassessment) - require_slice_discussion: false # pause auto-mode before each slice for discussion + require_slice_discussion: false # pause autonomous mode before each slice for discussion ``` These are usually set automatically by `token_profile`, but can be overridden explicitly. @@ -276,7 +276,7 @@ These are usually set automatically by `token_profile`, but can be overridden ex ### `skill_discovery` -Controls how SF finds and applies skills during auto mode. +Controls how SF finds and applies skills during autonomous mode. | Value | Behavior | |-------|----------| @@ -286,20 +286,20 @@ Controls how SF finds and applies skills during auto mode. ### `auto_supervisor` -Timeout thresholds for auto mode supervision: +Timeout thresholds for autonomous mode supervision: ```yaml auto_supervisor: model: claude-sonnet-4-6 # optional: model for supervisor (defaults to active model) soft_timeout_minutes: 20 # warn LLM to wrap up idle_timeout_minutes: 10 # detect stalls - hard_timeout_minutes: 30 # pause auto mode + hard_timeout_minutes: 30 # pause autonomous mode completion_nudge_after: 10 # complete-slice tool calls before nudging sf_slice_complete ``` ### `budget_ceiling` -Maximum USD to spend during auto mode. No `$` sign — just the number. +Maximum USD to spend during autonomous mode. No `$` sign — just the number. ```yaml budget_ceiling: 50.00 @@ -312,12 +312,12 @@ How the budget ceiling is enforced: | Value | Behavior | |-------|----------| | `warn` | Log a warning but continue | -| `pause` | Pause auto mode (default when ceiling is set) | -| `halt` | Stop auto mode entirely | +| `pause` | Pause autonomous mode (default when ceiling is set) | +| `halt` | Stop autonomous mode entirely | ### `context_pause_threshold` -Context window usage percentage (0-100) at which auto mode pauses for checkpointing. Set to `0` to disable. +Context window usage percentage (0-100) at which autonomous mode pauses for checkpointing. Set to `0` to disable. ```yaml context_pause_threshold: 80 # pause at 80% context usage @@ -439,7 +439,7 @@ git: | `commit_type` | string | (inferred) | Override conventional commit prefix (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`) | | `main_branch` | string | `"main"` | Primary branch name | | `merge_strategy` | string | `"squash"` | How worktree branches merge: `"squash"` (combine all commits) or `"merge"` (preserve individual commits) | -| `isolation` | string | `"worktree"` | Auto-mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) | +| `isolation` | string | `"worktree"` | Autonomous mode isolation: `"worktree"` (separate directory), `"branch"` (work in project root — useful for submodule-heavy repos), or `"none"` (no isolation — commits on current branch, no worktree or milestone branch) | | `commit_docs` | boolean | `true` | Commit `.sf/` planning artifacts to git. Set `false` to keep local-only | | `manage_gitignore` | boolean | `true` | When `false`, SF will not modify `.gitignore` at all — no baseline patterns, no self-healing. Use if you manage your own `.gitignore` | | `worktree_post_create` | string | (none) | Script to run after worktree creation. Receives `SOURCE_DIR` and `WORKTREE_DIR` env vars | @@ -448,7 +448,7 @@ git: #### `git.worktree_post_create` -Script to run after a worktree is created (both auto-mode and manual `/worktree`). Useful for copying `.env` files, symlinking asset directories, or running setup commands that worktrees don't inherit from the main tree. +Script to run after a worktree is created (both autonomous mode and manual `/worktree`). Useful for copying `.env` files, symlinking asset directories, or running setup commands that worktrees don't inherit from the main tree. ```yaml git: @@ -524,7 +524,7 @@ github: ### `notifications` -Control what notifications SF sends during auto mode: +Control what notifications SF sends during autonomous mode: ```yaml notifications: @@ -546,7 +546,7 @@ Why: `osascript display notification` is attributed to your terminal app (Ghostt ### `remote_questions` -Route interactive questions to Slack or Discord for headless auto mode: +Route interactive questions to Slack or Discord for headless autonomous mode: ```yaml remote_questions: @@ -701,7 +701,7 @@ dynamic_routing: ### `context_management` (v2.59) -Controls observation masking and tool result truncation during auto-mode sessions. Reduces context bloat between compactions with zero LLM overhead. +Controls observation masking and tool result truncation during autonomous mode sessions. Reduces context bloat between compactions with zero LLM overhead. ```yaml context_management: diff --git a/docs/user-docs/cost-management.md b/docs/user-docs/cost-management.md index 2574f0d10..aa449547e 100644 --- a/docs/user-docs/cost-management.md +++ b/docs/user-docs/cost-management.md @@ -1,6 +1,6 @@ # Cost Management -SF tracks token usage and cost for every unit of work dispatched during auto mode. This data powers the dashboard, budget enforcement, and cost projections. +SF tracks token usage and cost for every unit of work dispatched during autonomous mode. This data powers the dashboard, budget enforcement, and cost projections. ## Cost Tracking @@ -46,8 +46,8 @@ budget_enforcement: pause # default when ceiling is set | Mode | Behavior | |------|----------| | `warn` | Log a warning, continue executing | -| `pause` | Pause auto mode, wait for user action | -| `halt` | Stop auto mode entirely | +| `pause` | Pause autonomous mode, wait for user action | +| `halt` | Stop autonomous mode entirely | ## Cost Projections diff --git a/docs/user-docs/dynamic-model-routing.md b/docs/user-docs/dynamic-model-routing.md index 41799df23..a6efe9321 100644 --- a/docs/user-docs/dynamic-model-routing.md +++ b/docs/user-docs/dynamic-model-routing.md @@ -8,7 +8,7 @@ Starting in v2.52.0, the router uses **capability-aware scoring** to select the ## How It Works -Each unit dispatched by auto-mode passes through a two-stage pipeline: +Each unit dispatched by autonomous mode passes through a two-stage pipeline: **Stage 1: Complexity classification** — classifies the work into a tier (light/standard/heavy). diff --git a/docs/user-docs/getting-started.md b/docs/user-docs/getting-started.md index e9a1cfabe..246b755e3 100644 --- a/docs/user-docs/getting-started.md +++ b/docs/user-docs/getting-started.md @@ -330,7 +330,7 @@ Step mode keeps you in the loop, reviewing output between each step. ### Autonomous Mode — `/sf autonomous` -Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. `/sf auto` remains available as a short alias. +Type `/sf autonomous` and walk away. SF researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. `/sf autonomous` remains available as a short alias. ``` /sf autonomous diff --git a/docs/user-docs/git-strategy.md b/docs/user-docs/git-strategy.md index 957dd1a42..5887f8d20 100644 --- a/docs/user-docs/git-strategy.md +++ b/docs/user-docs/git-strategy.md @@ -91,7 +91,7 @@ SF-Task: M001/S01/T02 These features apply only in **worktree mode**. -### Automatic (Auto Mode) +### Automatic (Autonomous Mode) Auto mode creates and manages worktrees automatically: @@ -184,4 +184,4 @@ Run `/sf doctor` to check git health manually. ## Native Git Operations -Since v2.16, SF uses libgit2 via native bindings for read-heavy operations in the dispatch hot path. This eliminates ~70 process spawns per dispatch cycle, improving auto-mode throughput. +Since v2.16, SF uses libgit2 via native bindings for read-heavy operations in the dispatch hot path. This eliminates ~70 process spawns per dispatch cycle, improving autonomous mode throughput. diff --git a/docs/user-docs/providers.md b/docs/user-docs/providers.md index 9e0f1bd55..4c8bfa07b 100644 --- a/docs/user-docs/providers.md +++ b/docs/user-docs/providers.md @@ -80,65 +80,11 @@ SF detects your local Claude Code installation and uses it as the authenticated > **Note:** SF does not support browser-based OAuth sign-in for Anthropic. Use an API key or the Claude Code CLI instead. -**Option C — Use your Claude Pro/Max plan with SF inside Claude Code:** - -If you already have a Claude Pro or Max subscription and want to use SF's planning, execution, and milestone orchestration directly from Claude Code — without switching to a separate terminal — you can connect SF as an MCP server. This gives Claude Code access to SF's full workflow toolset via the [Model Context Protocol](https://modelcontextprotocol.io), so you get SF's structured project management powered by your existing Claude plan. - -**Automatic setup (recommended):** - -When SF detects a Claude Code model during startup, it automatically writes a `.mcp.json` file in your project root with the SF workflow MCP server configured. No manual steps needed — just start SF once with Claude Code as the provider and the config is created for you. - -You can also trigger this manually from inside a SF session: - -```bash -/sf mcp init -``` - -This writes (or updates) the `sf-workflow` entry in your project's `.mcp.json`. Claude Code discovers this file automatically on its next session start. - -**Manual setup:** - -If you prefer to configure it yourself, add SF to your project's `.mcp.json`: - -```json -{ - "mcpServers": { - "sf": { - "command": "npx", - "args": ["sf-mcp-server"], - "env": { - "SF_CLI_PATH": "/path/to/sf" - } - } - } -} -``` - -Or if `sf-mcp-server` is installed globally: - -```json -{ - "mcpServers": { - "sf": { - "command": "sf-mcp-server" - } - } -} -``` - -You can also add this to `~/.claude/settings.json` under `mcpServers` to make SF available across all projects. - -**What's exposed:** - -The MCP server provides SF's full workflow tool surface — milestone planning, task completion, slice management, roadmap reassessment, journal queries, and more. Session management tools (`sf_execute`, `sf_status`, `sf_result`, `sf_cancel`) let Claude Code start and monitor SF auto-mode sessions. See [Commands → MCP Server Mode](./commands.md#mcp-server-mode) for the full tool list. - -**Verify the connection:** - -From inside a SF session, check that the MCP server is reachable: - -```bash -/sf mcp status -``` +**Runtime boundary:** SF may use Claude Code, Codex, or Gemini CLI core as +model/runtime adapters when configured. These adapters are not project MCP +dependencies, and SF does not expose its own workflow as an MCP server. Run SF +directly with `sf` or `/sf autonomous`; reserve MCP configuration for external +tools that SF may call. ### OpenAI diff --git a/docs/user-docs/remote-questions.md b/docs/user-docs/remote-questions.md index 91eace8e2..65bad7b75 100644 --- a/docs/user-docs/remote-questions.md +++ b/docs/user-docs/remote-questions.md @@ -1,6 +1,6 @@ # Remote Questions -Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless auto-mode. When SF encounters a decision point that needs human input, it posts the question to your configured channel and polls for a response. +Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless autonomous-mode. When SF encounters a decision point that needs human input, it posts the question to your configured channel and polls for a response. ## Setup @@ -77,7 +77,7 @@ remote_questions: ## How It Works -1. SF encounters a decision point during auto-mode +1. SF encounters a decision point during autonomous mode 2. The question is posted to your configured channel as a rich embed (Discord) or Block Kit message (Slack) 3. SF polls for a response at the configured interval 4. You respond by: @@ -99,7 +99,7 @@ remote_questions: ### Timeouts -If no response is received within `timeout_minutes`, the prompt times out and SF continues with a timeout result. The LLM handles timeouts according to the task context — typically by making a conservative default choice or pausing auto-mode. +If no response is received within `timeout_minutes`, the prompt times out and SF continues with a timeout result. The LLM handles timeouts according to the task context — typically by making a conservative default choice or pausing autonomous mode. ## Commands diff --git a/docs/user-docs/skills.md b/docs/user-docs/skills.md index d28cc6015..6118c888a 100644 --- a/docs/user-docs/skills.md +++ b/docs/user-docs/skills.md @@ -84,7 +84,7 @@ The skill catalog lives in [`src/resources/extensions/sf/skill-catalog.ts`](../s ## Skill Discovery -The `skill_discovery` preference controls how SF finds skills during auto mode: +The `skill_discovery` preference controls how SF finds skills during autonomous mode: | Mode | Behavior | |------|----------| @@ -147,11 +147,11 @@ Project-local skills can be committed to version control so team members share t ## Skill Lifecycle Management -SF tracks skill performance across auto-mode sessions and surfaces health data to help you maintain skill quality. +SF tracks skill performance across autonomous mode sessions and surfaces health data to help you maintain skill quality. ### Skill Telemetry -Every auto-mode unit records which skills were available and actively loaded. This data is stored in `metrics.json` alongside existing token and cost tracking. +Every autonomous mode unit records which skills were available and actively loaded. This data is stored in `metrics.json` alongside existing token and cost tracking. ### Skill Health Dashboard diff --git a/docs/user-docs/token-optimization.md b/docs/user-docs/token-optimization.md index d60f34133..3af5a68ba 100644 --- a/docs/user-docs/token-optimization.md +++ b/docs/user-docs/token-optimization.md @@ -281,9 +281,9 @@ The profile is resolved once and flows through the entire dispatch pipeline. Exp *Introduced in v2.59.0* -During auto-mode sessions, tool results accumulate in the conversation history and consume context window space. Observation masking replaces tool result content older than N user turns with a lightweight placeholder before each LLM call. This reduces token usage with zero LLM overhead — no summarization calls, no latency. +During autonomous mode sessions, tool results accumulate in the conversation history and consume context window space. Observation masking replaces tool result content older than N user turns with a lightweight placeholder before each LLM call. This reduces token usage with zero LLM overhead — no summarization calls, no latency. -Masking is enabled by default during auto-mode. Configure via preferences: +Masking is enabled by default during autonomous mode. Configure via preferences: ```yaml context_management: @@ -309,7 +309,7 @@ Individual tool results that exceed `tool_result_max_chars` (default: 800) are t *Introduced in v2.59.0* -When auto-mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.sf/milestones//anchors/.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files. +When autonomous mode transitions between phases (research → planning → execution), structured JSON anchors are written to `.sf/milestones//anchors/.json`. Downstream prompt builders inject these anchors so the next phase inherits intent, decisions, blockers, and next steps without re-inferring from artifact files. This reduces context drift — the 65% of enterprise agent failures caused by agents losing track of prior decisions across phase boundaries. diff --git a/docs/user-docs/troubleshooting.md b/docs/user-docs/troubleshooting.md index 5eabcddcd..8bcc4cc33 100644 --- a/docs/user-docs/troubleshooting.md +++ b/docs/user-docs/troubleshooting.md @@ -73,7 +73,7 @@ source ~/.zshrc - `postinstall` hangs on Linux (Playwright `--with-deps` triggering sudo) — fixed in v2.3.6+ - Node.js version too old — requires ≥ 24.0.0 -### Provider errors during auto mode +### Provider errors during autonomous mode **Symptoms:** Auto mode pauses with a provider error (rate limit, server error, auth failure). @@ -95,7 +95,7 @@ models: - openrouter/minimax/minimax-m2.5 ``` -**Headless mode:** `sf headless auto` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution. +**Headless mode:** `sf headless autonomous` auto-restarts the entire process on crash (default 3 attempts with exponential backoff). Combined with provider error auto-resume, this enables true overnight unattended execution. For common provider setup issues (role errors, streaming errors, model ID mismatches), see the [Provider Setup Guide — Common Pitfalls](./providers.md#common-pitfalls). @@ -129,7 +129,7 @@ rm -rf "$(dirname .sf)/.sf.lock" **What it means:** The milestone's `.sf/milestones//-META.json` still points at the branch that was active when the milestone started, but that branch has since been renamed or deleted. **Current behavior:** -- If SF can deterministically recover to a safe branch, it no longer hard-stops auto mode. +- If SF can deterministically recover to a safe branch, it no longer hard-stops autonomous mode. - Safe fallbacks are: - explicit `git.main_branch` when configured and present - the repo's detected default integration branch (for example `main` or `master`) @@ -142,7 +142,7 @@ rm -rf "$(dirname .sf)/.sf.lock" ### Transient `EBUSY` / `EPERM` / `EACCES` while writing `.sf/` files -**Symptoms:** On Windows, auto mode or doctor occasionally fails while updating `.sf/` files with errors like `EBUSY`, `EPERM`, or `EACCES`. +**Symptoms:** On Windows, autonomous mode or doctor occasionally fails while updating `.sf/` files with errors like `EBUSY`, `EPERM`, or `EACCES`. **Cause:** Antivirus, indexers, editors, or filesystem watchers can briefly lock the destination or temp file just as SF performs the atomic rename. @@ -171,7 +171,7 @@ rm -rf "$(dirname .sf)/.sf.lock" ### Non-JS project blocked by worktree health check -**Symptoms:** Worktree health check fails or blocks auto-mode in projects that don't use Node.js (e.g., Rust, Go, Python). +**Symptoms:** Worktree health check fails or blocks autonomous mode in projects that don't use Node.js (e.g., Rust, Go, Python). **Cause:** The worktree health check only recognized JavaScript ecosystems prior to v2.42.0. @@ -260,13 +260,13 @@ rm -rf "$(dirname .sf)/.sf.lock" ### Session lock stolen by `/sf` in another terminal -**Symptoms:** Running `/sf` (step mode) in a second terminal causes a running auto-mode session to lose its lock. +**Symptoms:** Running `/sf` (step mode) in a second terminal causes a running autonomous mode session to lose its lock. -**Fix:** Fixed in v2.36.0. Bare `/sf` no longer steals the session lock from a running auto-mode session. Upgrade to the latest version. +**Fix:** Fixed in v2.36.0. Bare `/sf` no longer steals the session lock from a running autonomous mode session. Upgrade to the latest version. ### Worktree commits landing on main instead of milestone branch -**Symptoms:** Auto-mode commits in a worktree end up on `main` instead of the `milestone/` branch. +**Symptoms:** Autonomous mode commits in a worktree end up on `main` instead of the `milestone/` branch. **Fix:** Fixed in v2.37.1. CWD is now realigned before dispatch and stale merge state is cleaned on failure. Upgrade to the latest version. @@ -280,7 +280,7 @@ rm -rf "$(dirname .sf)/.sf.lock" ## Recovery Procedures -### Reset auto mode state +### Reset autonomous mode state ```bash rm .sf/auto.lock @@ -309,7 +309,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte - **GitHub Issues:** [github.com/singularity-forge/sf-run/issues](https://github.com/singularity-forge/sf-run/issues) - **Dashboard:** `Ctrl+Alt+G` or `/sf status` for real-time diagnostics -- **Forensics:** `/sf forensics` for structured post-mortem analysis of auto-mode failures +- **Forensics:** `/sf forensics` for structured post-mortem analysis of autonomous mode failures - **Session logs:** `.sf/activity/` contains JSONL session dumps for crash forensics ## iTerm2-Specific Issues @@ -346,7 +346,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte **Symptoms:** `sf_decision_save`, `sf_requirement_update`, or `sf_summary_save` fail with this error. -**Cause:** The SQLite database wasn't initialized. This happens in manual `/sf` sessions (non-auto mode) on versions before v2.29. +**Cause:** The SQLite database wasn't initialized. This happens in manual `/sf` sessions (non-autonomous mode) on versions before v2.29. **Fix:** Updated in v2.29+ to auto-initialize the database on first tool call. Upgrade to the latest version. @@ -388,7 +388,7 @@ After installing, run `lsp reload` to restart detection without restarting SF. ### Notifications not appearing on macOS -**Symptoms:** `notifications.enabled: true` in preferences, but no desktop notifications appear during auto-mode (no milestone complete alerts, no budget warnings, no error notifications). No error messages logged. +**Symptoms:** `notifications.enabled: true` in preferences, but no desktop notifications appear during autonomous mode (no milestone complete alerts, no budget warnings, no error notifications). No error messages logged. **Cause:** SF uses `osascript display notification` as a fallback on macOS. This command is attributed to your terminal app (Ghostty, iTerm2, Alacritty, Kitty, Warp, etc.). If that app doesn't have notification permissions in System Settings → Notifications, macOS silently drops the notification — `osascript` exits 0 with no error. diff --git a/docs/user-docs/visualizer.md b/docs/user-docs/visualizer.md index 1696bd3fe..74c8149ab 100644 --- a/docs/user-docs/visualizer.md +++ b/docs/user-docs/visualizer.md @@ -71,7 +71,7 @@ Chronological execution history showing: - Model used - Token counts -Ordered by execution time, showing the full history of auto-mode dispatches. +Ordered by execution time, showing the full history of autonomous mode dispatches. ## Controls @@ -85,7 +85,7 @@ Ordered by execution time, showing the full history of auto-mode dispatches. ## Auto-Refresh -The visualizer refreshes data from disk every 2 seconds, so it stays current if opened alongside a running auto-mode session. +The visualizer refreshes data from disk every 2 seconds, so it stays current if opened alongside a running autonomous mode session. ## HTML Export (v2.26) diff --git a/docs/user-docs/web-interface.md b/docs/user-docs/web-interface.md index 56acafedc..59faccfa1 100644 --- a/docs/user-docs/web-interface.md +++ b/docs/user-docs/web-interface.md @@ -27,7 +27,7 @@ sf --web --host 0.0.0.0 --port 8080 --allowed-origins "https://example.com" ## Features - **Project management** — view milestones, slices, and tasks in a visual dashboard -- **Real-time progress** — server-sent events push status updates as auto-mode executes +- **Real-time progress** — server-sent events push status updates as autonomous mode executes - **Multi-project support** — manage multiple projects from a single browser tab via `?project=` URL parameter - **Change project root** — switch project directories from the web UI without restarting the server (v2.44) - **Onboarding flow** — API key setup and provider configuration through the browser diff --git a/docs/user-docs/working-in-teams.md b/docs/user-docs/working-in-teams.md index 65a4f02a2..f5c143823 100644 --- a/docs/user-docs/working-in-teams.md +++ b/docs/user-docs/working-in-teams.md @@ -84,7 +84,7 @@ If you have an existing project with `.sf/` blanket-ignored: ## Parallel Development -Multiple developers can run auto mode simultaneously on different milestones. Each developer: +Multiple developers can run autonomous mode simultaneously on different milestones. Each developer: - Gets their own worktree (`.sf/worktrees//`, gitignored) - Works on a unique `milestone/` branch diff --git a/docs/zh-CN/user-docs/auto-mode.md b/docs/zh-CN/user-docs/auto-mode.md index c4bb38b6f..5734717fc 100644 --- a/docs/zh-CN/user-docs/auto-mode.md +++ b/docs/zh-CN/user-docs/auto-mode.md @@ -1,6 +1,6 @@ # 自动模式 -自动模式是 SF 的自主执行引擎。运行 `/sf auto`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。 +自动模式是 SF 的自主执行引擎。运行 `/sf autonomous`,然后离开;回来时你会看到已经构建好的软件,以及干净的 git 历史。 ## 工作原理 @@ -59,9 +59,9 @@ SF 支持三种 milestone 隔离模式(通过偏好设置中的 `git.isolation ### 崩溃恢复 -自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/sf auto` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。 +自动模式会用锁文件跟踪当前工作单元。如果会话中途退出,下一次执行 `/sf autonomous` 时,会读取残留的会话文件,从所有已经落盘的工具调用中综合生成一份恢复简报,然后带着完整上下文继续执行。 -**Headless 自动重启(v2.26):** 当运行 `sf headless auto` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。 +**Headless 自动重启(v2.26):** 当运行 `sf headless autonomous` 时,崩溃会触发带指数退避的自动重启(5s → 10s → 30s 上限,默认最多 3 次)。通过 `--max-restarts N` 配置。SIGINT/SIGTERM 不会触发重启。结合崩溃恢复机制,这让真正的“跑一夜直到完成”成为可能。 ### Provider 错误恢复 @@ -213,7 +213,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 启动 ``` -/sf auto +/sf autonomous ``` ### 暂停 @@ -223,7 +223,7 @@ v2.28 通过多项机制强化了自动模式的可靠性:原子文件写入 ### 恢复 ``` -/sf auto +/sf autonomous ``` 自动模式会读取磁盘状态,并从中断处继续。 diff --git a/docs/zh-CN/user-docs/commands.md b/docs/zh-CN/user-docs/commands.md index 9d12fc4ab..0a646d9f3 100644 --- a/docs/zh-CN/user-docs/commands.md +++ b/docs/zh-CN/user-docs/commands.md @@ -6,10 +6,10 @@ |------|------| | `/sf` | Step mode:一次执行一个工作单元,并在每步之间暂停 | | `/sf next` | 显式 Step mode(与 `/sf` 相同) | -| `/sf auto` | 自动模式:research、plan、execute、commit,然后重复 | +| `/sf autonomous` | 自动模式:research、plan、execute、commit,然后重复 | | `/sf quick` | 在不经过完整 planning 开销的情况下,执行一个带 SF 保证的 quick task(原子提交、状态跟踪) | | `/sf stop` | 优雅地停止自动模式 | -| `/sf pause` | 暂停自动模式(保留状态,可用 `/sf auto` 恢复) | +| `/sf pause` | 暂停自动模式(保留状态,可用 `/sf autonomous` 恢复) | | `/sf steer` | 在执行过程中强制修改 plan 文档 | | `/sf discuss` | 讨论架构和决策(可与自动模式并行使用) | | `/sf status` | 进度仪表板 | @@ -268,17 +268,9 @@ sf headless query | jq '.cost.total' } ``` - -## MCP Server 模式 +## MCP 集成 -`sf --mode mcp` 会通过 stdin/stdout 将 SF 作为一个 [Model Context Protocol](https://modelcontextprotocol.io) server 运行。这会把所有 SF 工具(read、write、edit、bash 等)暴露给外部 AI 客户端,例如 Claude Desktop、VS Code Copilot,以及任何兼容 MCP 的宿主。 - -```bash -# 以 MCP server 模式启动 SF -sf --mode mcp -``` - -服务会注册 agent 会话中的全部工具,并把 MCP 的 `tools/list` 与 `tools/call` 请求映射到 SF 的工具定义上。连接会一直保持,直到底层 transport 关闭。 +`/sf mcp` 只显示外部 MCP 工具 server 的状态。SF 不会把自己的 workflow 暴露成 MCP server;请直接运行 `sf` 或 `/sf autonomous`。 ## 会话内更新 diff --git a/docs/zh-CN/user-docs/configuration.md b/docs/zh-CN/user-docs/configuration.md index ab40bfdae..d7de1509c 100644 --- a/docs/zh-CN/user-docs/configuration.md +++ b/docs/zh-CN/user-docs/configuration.md @@ -150,7 +150,7 @@ mcp_call(server="my-server", tool="", args={...}) - 尽量为本地可执行文件和脚本使用绝对路径 - 对于 `stdio` servers,优先在 MCP 配置里显式设置需要的环境变量,而不是依赖交互式 shell profile -- SF 和 `sf-mcp-server` 都会自动加载保存在 `~/.sf/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据 +- SF 会自动加载保存在 `~/.sf/agent/auth.json` 中的 model / tool keys,因此 MCP 配置可以安全地通过 `${ENV_VAR}` 占位符引用这些值,而不必提交原始凭据 - 如果某个 server 是团队共享且适合提交到仓库,通常更适合放在 `.mcp.json` - 如果某个 server 依赖本机路径、个人服务或本地 secrets,更适合放在 `.sf/mcp.json` diff --git a/docs/zh-CN/user-docs/getting-started.md b/docs/zh-CN/user-docs/getting-started.md index b44a4b29d..6d92d2416 100644 --- a/docs/zh-CN/user-docs/getting-started.md +++ b/docs/zh-CN/user-docs/getting-started.md @@ -294,7 +294,7 @@ docker sandbox exec -it sf-sandbox bash ```bash export ANTHROPIC_API_KEY="sk-ant-..." -sf auto "implement the feature described in issue #42" +sf autonomous "implement the feature described in issue #42" ``` 完整的配置、资源限制和 compose 文件请见 [Docker Sandbox 文档](../../../docker/README.md)。 @@ -328,12 +328,12 @@ sf auto "implement the feature described in issue #42" 步骤模式会让你始终留在回路中,在每一步之间查看和确认输出。 -### 自动模式 — `/sf auto` +### 自动模式 — `/sf autonomous` -输入 `/sf auto` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。 +输入 `/sf autonomous` 后就可以离开。SF 会自主完成 research、planning、execution、verification、commit,并持续推进每个 slice,直到 milestone 完成。 ``` -/sf auto +/sf autonomous ``` 完整细节请见 [自动模式](./auto-mode.md)。 @@ -348,7 +348,7 @@ sf auto "implement the feature described in issue #42" ```bash sf -/sf auto +/sf autonomous ``` **终端 2:在它工作时进行引导** diff --git a/docs/zh-CN/user-docs/providers.md b/docs/zh-CN/user-docs/providers.md index 20112f127..543f73424 100644 --- a/docs/zh-CN/user-docs/providers.md +++ b/docs/zh-CN/user-docs/providers.md @@ -83,65 +83,7 @@ SF 会检测你本地的 Claude Code 安装,并把它作为已认证的 Anthro > **注意:** SF 不支持 Anthropic 的浏览器 OAuth 登录。请改用 API key 或 Claude Code CLI。 -**选项 C:在 Claude Code 里直接用 Claude Pro / Max 订阅跑 SF** - -如果你已经有 Claude Pro / Max 订阅,并希望直接在 Claude Code 里使用 SF 的 planning、execution 和 milestone orchestration,而不是切到单独终端,那么可以把 SF 接成一个 MCP server。这样 Claude Code 就能通过 [Model Context Protocol](https://modelcontextprotocol.io) 使用 SF 的完整 workflow 工具集,在你现有 Claude plan 的驱动下获得 SF 的结构化项目管理能力。 - -**自动配置(推荐)** - -当 SF 在启动时检测到 Claude Code model,它会自动在项目根目录写入一个带有 SF workflow MCP server 配置的 `.mcp.json` 文件。无需手动步骤,只要以 Claude Code 作为 provider 启动一次 SF,配置就会自动生成。 - -你也可以在 SF 会话中手动触发: - -```bash -/sf mcp init -``` - -这会在项目的 `.mcp.json` 中写入(或更新)`sf-workflow` 条目。Claude Code 会在下一次启动会话时自动发现这个文件。 - -**手动配置** - -如果你更希望自己配置,可以把 SF 加到项目的 `.mcp.json` 中: - -```json -{ - "mcpServers": { - "sf": { - "command": "npx", - "args": ["sf-mcp-server"], - "env": { - "SF_CLI_PATH": "/path/to/sf" - } - } - } -} -``` - -如果 `sf-mcp-server` 已经全局安装: - -```json -{ - "mcpServers": { - "sf": { - "command": "sf-mcp-server" - } - } -} -``` - -你也可以把这段配置写到 `~/.claude/settings.json` 的 `mcpServers` 中,让 SF 在所有项目中都可用。 - -**暴露了什么** - -MCP server 会暴露 SF 的完整 workflow 工具面:milestone planning、task completion、slice 管理、roadmap reassessment、journal 查询等。会话管理工具(`sf_execute`、`sf_status`、`sf_result`、`sf_cancel`)允许 Claude Code 启动并监控 SF 自动模式会话。完整工具列表见 [命令 → MCP Server 模式](./commands.md#mcp-server-mode)。 - -**验证连接** - -在 SF 会话里检查 MCP server 是否可达: - -```bash -/sf mcp status -``` +**Runtime 边界:** SF 可以把 Claude Code、Codex 或 Gemini CLI core 作为 model/runtime adapter 使用。这些 adapter 不是项目 MCP 依赖,SF 也不会把自己的 workflow 暴露成 MCP server。请直接运行 `sf` 或 `/sf autonomous`;MCP 配置只用于 SF 需要调用的外部工具。 ### OpenAI diff --git a/docs/zh-CN/user-docs/troubleshooting.md b/docs/zh-CN/user-docs/troubleshooting.md index f780980c2..9e1280bf9 100644 --- a/docs/zh-CN/user-docs/troubleshooting.md +++ b/docs/zh-CN/user-docs/troubleshooting.md @@ -27,13 +27,13 @@ - 崩溃后的缓存过期:内存中的文件列表没有反映新产物 - LLM 没有生成预期的 artifact 文件 -**解决:** 先运行 `/sf doctor` 修复状态,然后执行 `/sf auto` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。 +**解决:** 先运行 `/sf doctor` 修复状态,然后执行 `/sf autonomous` 恢复。如果问题持续存在,检查预期 artifact 文件是否确实已经写到磁盘。 ### 自动模式因 “Loop detected” 停止 **原因:** 同一个单元连续两次没有生成预期 artifact。 -**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/sf auto` 恢复。 +**解决:** 检查 task plan 是否足够清晰。如果 plan 存在歧义,先手动澄清,再执行 `/sf autonomous` 恢复。 ### Worktree 中出现了错误文件 @@ -99,7 +99,7 @@ models: - openrouter/minimax/minimax-m2.5 ``` -**Headless 模式:** `sf headless auto` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。 +**Headless 模式:** `sf headless autonomous` 在进程崩溃时会自动重启整个进程(默认 3 次,带指数退避)。与 provider 错误自动恢复配合后,能支持真正的夜间无人值守运行。 常见的 provider 配置问题(role 错误、streaming 错误、model ID 不匹配)见 [Provider 设置指南:常见坑点](./providers.md#common-pitfalls)。 @@ -107,13 +107,13 @@ models: **症状:** 自动模式因 “Budget ceiling reached” 暂停。 -**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/sf auto` 恢复。 +**解决:** 提高偏好设置中的 `budget_ceiling`,或者切换到 `budget` token profile 降低每个工作单元成本,然后再执行 `/sf autonomous` 恢复。 ### 过期锁文件 **症状:** 自动模式无法启动,提示另一个会话正在运行。 -**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/sf auto` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.sf.lock/` 目录。如果自动恢复失败,可手动删除 `.sf/auto.lock` 和 `.sf.lock/`: +**解决:** SF 会自动检测过期锁:如果持有锁的 PID 已死亡,则在下次 `/sf autonomous` 时清理并重新获取锁。它也会处理 `proper-lockfile` 崩溃后遗留的 `.sf.lock/` 目录。如果自动恢复失败,可手动删除 `.sf/auto.lock` 和 `.sf.lock/`: ```bash rm -f .sf/auto.lock @@ -304,7 +304,7 @@ rm .sf/auto.lock rm .sf/completed-units.json ``` -然后执行 `/sf auto`,从当前磁盘状态重新开始。 +然后执行 `/sf autonomous`,从当前磁盘状态重新开始。 ### 重置路由历史 diff --git a/gitbook/README.md b/gitbook/README.md index e80306a53..890254805 100644 --- a/gitbook/README.md +++ b/gitbook/README.md @@ -22,7 +22,7 @@ You can stay hands-on with **step mode** (reviewing each step) or let SF run aut ## Key Features -- **Autonomous execution** — `/sf auto` runs research, planning, coding, testing, and committing without intervention +- **Autonomous execution** — `/sf autonomous` runs research, planning, coding, testing, and committing without intervention - **20+ LLM providers** — Anthropic, OpenAI, Google, OpenRouter, GitHub Copilot, Amazon Bedrock, local models, and more - **Git isolation** — Each milestone works in its own worktree branch, merged cleanly when done - **Cost tracking** — Real-time token usage, budget ceilings, and automatic model downgrading @@ -44,7 +44,7 @@ npm install -g sf-run sf # Start autonomous mode -/sf auto +/sf autonomous ``` See [Installation](getting-started/installation.md) for detailed setup instructions. @@ -54,7 +54,7 @@ See [Installation](getting-started/installation.md) for detailed setup instructi | Mode | Command | Best For | |------|---------|----------| | **Step** | `/sf` | Staying in the loop, reviewing each step | -| **Auto** | `/sf auto` | Walking away, overnight builds, batch work | +| **Auto** | `/sf autonomous` | Walking away, overnight builds, batch work | The recommended workflow: run auto mode in one terminal, steer from another. See [Step Mode](core-concepts/step-mode.md) and [Auto Mode](core-concepts/auto-mode.md). diff --git a/gitbook/core-concepts/auto-mode.md b/gitbook/core-concepts/auto-mode.md index b3b27e3c9..becbcde4a 100644 --- a/gitbook/core-concepts/auto-mode.md +++ b/gitbook/core-concepts/auto-mode.md @@ -1,11 +1,11 @@ # Auto Mode -Auto mode is SF's autonomous execution engine. Run `/sf auto`, walk away, come back to built software with clean git history. +Auto mode is SF's autonomous execution engine. Run `/sf autonomous`, walk away, come back to built software with clean git history. ## Starting Auto Mode ``` -/sf auto +/sf autonomous ``` SF reads `.sf/STATE.md`, determines the next unit of work, creates a fresh AI session with all relevant context, and lets the AI execute. When it finishes, SF reads disk state again and dispatches the next unit. This continues until the milestone is complete. @@ -35,7 +35,7 @@ Press **Escape**. The conversation is preserved. You can interact with the agent ### Resume ``` -/sf auto +/sf autonomous ``` Auto mode reads disk state and picks up where it left off. @@ -82,9 +82,9 @@ In worktree mode, all commits are squash-merged to main as one clean commit when ## Crash Recovery -If a session dies, the next `/sf auto` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. +If a session dies, the next `/sf autonomous` reads the surviving session file, synthesizes a recovery briefing from every tool call that made it to disk, and resumes with full context. -In headless mode (`sf headless auto`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution. +In headless mode (`sf headless autonomous`), crashes trigger automatic restart with exponential backoff (5s → 10s → 30s, up to 3 attempts). Combined with crash recovery, this enables true overnight "fire and forget" execution. ## Provider Error Recovery diff --git a/gitbook/core-concepts/step-mode.md b/gitbook/core-concepts/step-mode.md index 7c7d9f4dc..5c48497a5 100644 --- a/gitbook/core-concepts/step-mode.md +++ b/gitbook/core-concepts/step-mode.md @@ -34,7 +34,7 @@ Between steps, you can: - **Discuss** — `/sf discuss` to talk through architecture decisions - **Skip** — `/sf skip` to prevent a unit from being dispatched - **Undo** — `/sf undo` to revert the last completed unit -- **Switch to auto** — `/sf auto` to let SF continue autonomously +- **Switch to auto** — `/sf autonomous` to let SF continue autonomously ## When to Use Step Mode @@ -48,7 +48,7 @@ Between steps, you can: Once you're comfortable with SF's approach, switch to auto mode: ``` -/sf auto +/sf autonomous ``` You can always press **Escape** to pause auto mode and return to step-by-step control. diff --git a/gitbook/features/headless.md b/gitbook/features/headless.md index fce3f94f8..7dc46b034 100644 --- a/gitbook/features/headless.md +++ b/gitbook/features/headless.md @@ -71,15 +71,9 @@ sf headless query | jq '.cost.total' Any `/sf` subcommand works as a positional argument: `sf headless status`, `sf headless doctor`, etc. -## MCP Server Mode +## MCP Integrations -`sf --mode mcp` runs SF as a Model Context Protocol server over stdin/stdout, exposing all SF tools to external AI clients: - -```bash -sf --mode mcp -``` - -Compatible with Claude Desktop, VS Code Copilot, and any MCP host. +`/sf mcp` reports configured external MCP tool servers. SF does not expose its own workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`. ## Auto-Restart diff --git a/gitbook/features/remote-questions.md b/gitbook/features/remote-questions.md index f4e3daa97..3825615d3 100644 --- a/gitbook/features/remote-questions.md +++ b/gitbook/features/remote-questions.md @@ -1,6 +1,6 @@ # Remote Questions -Remote questions let SF ask for your input via Slack, Discord, or Telegram when running in headless auto mode. When SF needs a decision, it posts the question to your configured channel and polls for a response. +Remote questions let SF ask for your input via Slack, Discord, or Telegram when running in headless autonomous mode. When SF needs a decision, it posts the question to your configured channel and polls for a response. ## Setup diff --git a/gitbook/getting-started/first-project.md b/gitbook/getting-started/first-project.md index c8fc63012..228000304 100644 --- a/gitbook/getting-started/first-project.md +++ b/gitbook/getting-started/first-project.md @@ -38,7 +38,7 @@ The key rule: **a task must fit in one AI context window.** If it can't, it beco Once you have a milestone and roadmap, let SF take the wheel: ``` -/sf auto +/sf autonomous ``` SF autonomously: @@ -56,7 +56,7 @@ The recommended approach: auto mode in one terminal, steering from another. ```bash sf -/sf auto +/sf autonomous ``` **Terminal 2 — steer while it works:** diff --git a/gitbook/reference/commands.md b/gitbook/reference/commands.md index 991607981..d26b33745 100644 --- a/gitbook/reference/commands.md +++ b/gitbook/reference/commands.md @@ -5,10 +5,10 @@ | Command | Description | |---------|-------------| | `/sf` | Step mode — execute one unit at a time | -| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat | +| `/sf autonomous` | Autonomous mode — research, plan, execute, commit, repeat | | `/sf quick` | Quick task with SF guarantees but no full planning | -| `/sf stop` | Stop auto mode gracefully | -| `/sf pause` | Pause auto mode (preserves state) | +| `/sf stop` | Stop autonomous mode gracefully | +| `/sf pause` | Pause autonomous mode (preserves state) | | `/sf steer` | Modify plan documents during execution | | `/sf discuss` | Discuss architecture and decisions | | `/sf status` | Progress dashboard | @@ -18,7 +18,7 @@ | `/sf triage` | Manually trigger capture triage | | `/sf dispatch` | Dispatch a specific phase directly | | `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | -| `/sf forensics` | Full debugger for auto-mode failures | +| `/sf forensics` | Full debugger for autonomous mode failures | | `/sf cleanup` | Clean up state files and stale worktrees | | `/sf visualize` | Open workflow visualizer | | `/sf export --html` | Generate HTML report for current milestone | @@ -29,7 +29,7 @@ | `/sf rate` | Rate last unit's model tier (over/ok/under) | | `/sf changelog` | Show release notes | | `/sf logs` | Browse activity and debug logs | -| `/sf remote` | Control remote auto-mode | +| `/sf remote` | Control remote autonomous mode | | `/sf help` | Show all available commands | ## Configuration & Diagnostics @@ -53,7 +53,7 @@ | Command | Description | |---------|-------------| | `/sf new-milestone` | Create a new milestone | -| `/sf skip` | Prevent a unit from auto-mode dispatch | +| `/sf skip` | Prevent a unit from autonomous mode dispatch | | `/sf undo` | Revert last completed unit | | `/sf undo-task` | Reset a specific task's completion state | | `/sf reset-slice` | Reset a slice and all its tasks | @@ -88,7 +88,7 @@ | `/sf workflow run ` | Start a workflow run | | `/sf workflow list` | List workflow runs | | `/sf workflow validate ` | Validate a workflow YAML | -| `/sf workflow pause` | Pause workflow auto-mode | +| `/sf workflow pause` | Pause workflow autonomous mode | | `/sf workflow resume` | Resume paused workflow | ## Extensions diff --git a/gitbook/reference/troubleshooting.md b/gitbook/reference/troubleshooting.md index cfbf6ffd3..3ed32a1a0 100644 --- a/gitbook/reference/troubleshooting.md +++ b/gitbook/reference/troubleshooting.md @@ -16,13 +16,13 @@ It checks file structure, roadmap ↔ slice ↔ task consistency, completion sta The same unit dispatches repeatedly. -**Fix:** Run `/sf doctor` to repair state, then `/sf auto`. If it persists, check that the expected artifact file exists on disk. +**Fix:** Run `/sf doctor` to repair state, then `/sf autonomous`. If it persists, check that the expected artifact file exists on disk. ### Auto mode stops with "Loop detected" A unit failed to produce its expected artifact twice. -**Fix:** Check the task plan for clarity. Refine it manually, then `/sf auto`. +**Fix:** Check the task plan for clarity. Refine it manually, then `/sf autonomous`. ### `command not found: sf` after install @@ -63,7 +63,7 @@ models: Auto mode pauses with "Budget ceiling reached." -**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/sf auto`. +**Fix:** Increase `budget_ceiling` in preferences, or switch to `budget` token profile, then `/sf autonomous`. ### Stale lock file @@ -115,7 +115,7 @@ rm .sf/auto.lock rm .sf/completed-units.json ``` -Then `/sf auto` to restart from current state. +Then `/sf autonomous` to restart from current state. ### Reset routing history diff --git a/mintlify-docs/getting-started.mdx b/mintlify-docs/getting-started.mdx index 4aaba68dc..6c39fc4ff 100644 --- a/mintlify-docs/getting-started.mdx +++ b/mintlify-docs/getting-started.mdx @@ -68,10 +68,10 @@ Or configure per-phase models in [preferences](/guides/configuration). - **Mid-task** → resume where you left off - Type `/sf auto` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. + Type `/sf autonomous` and walk away. SF autonomously researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. ``` - /sf auto + /sf autonomous ``` See [auto mode](/guides/auto-mode) for the full details. @@ -86,7 +86,7 @@ The recommended workflow: auto mode in one terminal, steering from another. ```bash sf -/sf auto +/sf autonomous ``` **Terminal 2 — steer while it works:** diff --git a/mintlify-docs/guides/auto-mode.mdx b/mintlify-docs/guides/auto-mode.mdx index 7c76a9522..277033bb3 100644 --- a/mintlify-docs/guides/auto-mode.mdx +++ b/mintlify-docs/guides/auto-mode.mdx @@ -1,6 +1,6 @@ --- title: "Auto mode" -description: "SF's autonomous execution engine — run /sf auto, walk away, come back to built software with clean git history." +description: "SF's autonomous execution engine — run /sf autonomous, walk away, come back to built software with clean git history." --- Auto mode is a **state machine driven by files on disk**. It reads `.sf/STATE.md`, determines the next unit of work, creates a fresh agent session with pre-loaded context, and lets the LLM execute. When the LLM finishes, auto mode reads disk state again and dispatches the next unit. @@ -50,9 +50,9 @@ See [git strategy](/guides/git-strategy) for details. ### Crash recovery -A lock file tracks the current unit. If the session dies, the next `/sf auto` synthesizes a recovery briefing from tool calls that made it to disk and resumes with full context. +A lock file tracks the current unit. If the session dies, the next `/sf autonomous` synthesizes a recovery briefing from tool calls that made it to disk and resumes with full context. -**Headless auto-restart:** When running `sf headless auto`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Combined with crash recovery, this enables overnight "run until done" execution. +**Headless auto-restart:** When running `sf headless autonomous`, crashes trigger automatic restart with exponential backoff (5s → 10s → 30s cap, default 3 attempts). Combined with crash recovery, this enables overnight "run until done" execution. ### Provider error recovery @@ -122,7 +122,7 @@ reactive_execution: true # disabled by default ``` - /sf auto + /sf autonomous ``` @@ -130,7 +130,7 @@ reactive_execution: true # disabled by default ``` - /sf auto + /sf autonomous ``` Auto mode reads disk state and picks up where it left off. diff --git a/mintlify-docs/guides/change-management.mdx b/mintlify-docs/guides/change-management.mdx index 8adc46749..55a22f822 100644 --- a/mintlify-docs/guides/change-management.mdx +++ b/mintlify-docs/guides/change-management.mdx @@ -95,7 +95,7 @@ For structural changes (adding tasks, removing tasks), the agent triggers a slic ``` - /sf auto + /sf autonomous ``` Auto-mode dispatches the next active milestone in queue order. diff --git a/mintlify-docs/guides/commands.mdx b/mintlify-docs/guides/commands.mdx index 3129b28e6..9934f9170 100644 --- a/mintlify-docs/guides/commands.mdx +++ b/mintlify-docs/guides/commands.mdx @@ -9,22 +9,22 @@ description: "Every SF command, keyboard shortcut, and CLI flag." |---------|-------------| | `/sf` | Step mode — execute one unit at a time, pause between each | | `/sf next` | Explicit step mode (same as `/sf`) | -| `/sf auto` | Autonomous mode — research, plan, execute, commit, repeat | +| `/sf autonomous` | Autonomous mode — research, plan, execute, commit, repeat | | `/sf quick` | Execute a quick task with SF guarantees without full planning overhead | -| `/sf stop` | Stop auto mode gracefully | -| `/sf pause` | Pause auto mode (preserves state, `/sf auto` to resume) | +| `/sf stop` | Stop autonomous mode gracefully | +| `/sf pause` | Pause autonomous mode (preserves state, `/sf autonomous` to resume) | | `/sf steer` | Hard-steer plan documents during execution | -| `/sf discuss` | Discuss architecture and decisions (works alongside auto mode) | +| `/sf discuss` | Discuss architecture and decisions (works alongside autonomous mode) | | `/sf rethink` | Conversational project reorganization | | `/sf mcp` | MCP server status and connectivity | | `/sf status` | Progress dashboard | | `/sf widget` | Cycle dashboard widget: full / small / min / off | -| `/sf queue` | Queue and reorder future milestones (safe during auto mode) | -| `/sf capture` | Fire-and-forget thought capture (works during auto mode) | +| `/sf queue` | Queue and reorder future milestones (safe during autonomous mode) | +| `/sf capture` | Fire-and-forget thought capture (works during autonomous mode) | | `/sf triage` | Manually trigger triage of pending captures | | `/sf dispatch` | Dispatch a specific phase directly | | `/sf history` | View execution history (supports `--cost`, `--phase`, `--model` filters) | -| `/sf forensics` | Full-access debugger for auto-mode failures | +| `/sf forensics` | Full-access debugger for autonomous mode failures | | `/sf cleanup` | Clean up SF state files and stale worktrees | | `/sf visualize` | Open workflow visualizer | | `/sf export --html` | Generate self-contained HTML report | @@ -35,7 +35,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag." | `/sf rate` | Rate last unit's model tier (over/ok/under) | | `/sf changelog` | Show categorized release notes | | `/sf logs` | Browse activity logs, debug logs, and metrics | -| `/sf remote` | Control remote auto-mode | +| `/sf remote` | Control remote autonomous mode | | `/sf help` | Categorized command reference | ## Configuration and diagnostics @@ -60,7 +60,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag." | Command | Description | |---------|-------------| | `/sf new-milestone` | Create a new milestone | -| `/sf skip` | Prevent a unit from auto-mode dispatch | +| `/sf skip` | Prevent a unit from autonomous mode dispatch | | `/sf undo` | Revert last completed unit | | `/sf undo-task` | Reset a specific task's completion state | | `/sf reset-slice` | Reset a slice and all its tasks | @@ -92,11 +92,11 @@ description: "Every SF command, keyboard shortcut, and CLI flag." | Command | Description | |---------|-------------| | `/sf workflow new` | Create a new workflow definition | -| `/sf workflow run ` | Create a run and start auto-mode | +| `/sf workflow run ` | Create a run and start autonomous mode | | `/sf workflow list` | List workflow runs | | `/sf workflow validate ` | Validate a workflow definition | -| `/sf workflow pause` | Pause custom workflow auto-mode | -| `/sf workflow resume` | Resume paused custom workflow auto-mode | +| `/sf workflow pause` | Pause custom workflow autonomous mode | +| `/sf workflow resume` | Resume paused custom workflow autonomous mode | ## Extensions @@ -115,7 +115,7 @@ description: "Every SF command, keyboard shortcut, and CLI flag." | `Ctrl+Alt+V` | Toggle voice transcription | | `Ctrl+Alt+B` | Show background shell processes | | `Ctrl+V` / `Alt+V` | Paste image from clipboard | -| `Escape` | Pause auto mode | +| `Escape` | Pause autonomous mode | In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBrains IDEs), slash-command fallbacks are shown instead of `Ctrl+Alt` shortcuts. @@ -145,7 +145,7 @@ In terminals without Kitty keyboard protocol support (macOS Terminal.app, JetBra `sf headless` runs commands without a TUI — designed for CI, cron jobs, and scripted automation. ```bash -sf headless # run auto mode +sf headless # run autonomous mode sf headless next # run a single unit sf headless query # instant JSON snapshot (~50ms, no LLM) sf headless --timeout 600000 auto # with timeout @@ -159,7 +159,7 @@ sf headless new-milestone --context brief.md --auto | `--json` | Stream events as JSONL to stdout | | `--model ID` | Override the model | | `--context ` | Context file for `new-milestone` (use `-` for stdin) | -| `--auto` | Chain into auto-mode after milestone creation | +| `--auto` | Chain into autonomous mode after milestone creation | **Exit codes:** `0` = complete, `1` = error/timeout, `2` = blocked. @@ -173,10 +173,6 @@ sf headless query | jq '.next' # next dispatch action sf headless query | jq '.cost.total' # total spend ``` -## MCP server mode +## MCP integrations -```bash -sf --mode mcp -``` - -Runs SF as a Model Context Protocol server over stdin/stdout, exposing all tools to external AI clients (Claude Desktop, VS Code Copilot, etc.). +`/sf mcp` shows configured external MCP tool servers. SF does not expose its own workflow as an MCP server; run SF directly with `sf` or `/sf autonomous`. diff --git a/mintlify-docs/guides/remote-questions.mdx b/mintlify-docs/guides/remote-questions.mdx index 02988a389..b27c8631f 100644 --- a/mintlify-docs/guides/remote-questions.mdx +++ b/mintlify-docs/guides/remote-questions.mdx @@ -1,9 +1,9 @@ --- title: "Remote questions" -description: "Discord, Slack, and Telegram integration for headless auto-mode." +description: "Discord, Slack, and Telegram integration for headless autonomous-mode." --- -Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless auto-mode. When SF encounters a decision point, it posts the question to your configured channel and polls for a response. +Remote questions allow SF to ask for user input via Slack, Discord, or Telegram when running in headless autonomous-mode. When SF encounters a decision point, it posts the question to your configured channel and polls for a response. ## Setup diff --git a/mintlify-docs/guides/troubleshooting.mdx b/mintlify-docs/guides/troubleshooting.mdx index c38704021..025849095 100644 --- a/mintlify-docs/guides/troubleshooting.mdx +++ b/mintlify-docs/guides/troubleshooting.mdx @@ -19,13 +19,13 @@ It checks file structure, referential integrity, completion state consistency, g **Cause:** Stale cache after a crash, or the LLM didn't produce the expected artifact. - **Fix:** Run `/sf doctor` to repair state, then `/sf auto`. + **Fix:** Run `/sf doctor` to repair state, then `/sf autonomous`. **Cause:** A unit failed to produce its expected artifact twice in a row. - **Fix:** Check the task plan for clarity. Refine it manually, then `/sf auto`. + **Fix:** Check the task plan for clarity. Refine it manually, then `/sf autonomous`. @@ -59,7 +59,7 @@ It checks file structure, referential integrity, completion state consistency, g - Increase `budget_ceiling` in preferences, or switch to `budget` token profile. Resume with `/sf auto`. + Increase `budget_ceiling` in preferences, or switch to `budget` token profile. Resume with `/sf autonomous`. @@ -134,7 +134,7 @@ rm .sf/auto.lock rm .sf/completed-units.json ``` -Then `/sf auto` to restart from current disk state. +Then `/sf autonomous` to restart from current disk state. ### Reset routing history diff --git a/mintlify-docs/introduction.mdx b/mintlify-docs/introduction.mdx index a9a4a601f..8908c79d5 100644 --- a/mintlify-docs/introduction.mdx +++ b/mintlify-docs/introduction.mdx @@ -3,7 +3,7 @@ title: "SF — Singularity Forge" description: "An autonomous coding agent that researches, plans, executes, and commits code while you focus on what matters." --- -SF is an autonomous coding agent. Describe what you want built, run `/sf auto`, and walk away. Come back to working software with clean git history. +SF is an autonomous coding agent. Describe what you want built, run `/sf autonomous`, and walk away. Come back to working software with clean git history. ## What SF does @@ -56,11 +56,11 @@ Every phase gets a fresh context window with pre-loaded context — no accumulat ``` - Type `/sf auto` and walk away. SF autonomously researches, plans, executes, verifies, and commits until the milestone is complete. + Type `/sf autonomous` and walk away. SF autonomously researches, plans, executes, verifies, and commits until the milestone is complete. ```bash sf - /sf auto + /sf autonomous ``` @@ -71,7 +71,7 @@ The recommended workflow: auto mode in one terminal, steering from another. ```bash sf -/sf auto +/sf autonomous ``` **Terminal 2 — steer while it works:** diff --git a/package-lock.json b/package-lock.json index ecce7085d..fb31993db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6026,10 +6026,6 @@ "node_modules/@singularity-forge/engine-win32-x64-msvc": { "optional": true }, - "node_modules/@singularity-forge/mcp-server": { - "resolved": "packages/mcp-server", - "link": true - }, "node_modules/@singularity-forge/native": { "resolved": "packages/native", "link": true @@ -16349,27 +16345,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "packages/mcp-server": { - "name": "@singularity-forge/mcp-server", - "version": "2.75.0", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "@singularity-forge/pi-agent-core": "^2.75.0", - "@singularity-forge/rpc-client": "^2.75.0", - "zod": "^4.0.0" - }, - "bin": { - "sf-mcp-server": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^24.12.0", - "typescript": "^5.4.0" - }, - "engines": { - "node": ">=24.15.0" - } - }, "packages/native": { "name": "@singularity-forge/native", "version": "2.75.0", diff --git a/package.json b/package.json index 9a394dc9c..3e06f9b65 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,8 @@ "build:native-pkg": "npm --workspace @singularity-forge/native run build", "build:rpc-client": "npm --workspace @singularity-forge/rpc-client run build", "build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent", - "build:mcp-server": "npm --workspace @singularity-forge/mcp-server run build", "build:daemon": "npm --workspace @singularity-forge/daemon run build", - "build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run build:mcp-server && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html", + "build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html", "build": "npm run build:core && node scripts/build-web-if-stale.cjs", "stage:web-host": "node scripts/stage-web-standalone.cjs", "build:web-host": "npm --prefix web run build && npm run stage:web-host", diff --git a/packages/daemon/src/session-manager.ts b/packages/daemon/src/session-manager.ts index f84bb9223..0ef86af49 100644 --- a/packages/daemon/src/session-manager.ts +++ b/packages/daemon/src/session-manager.ts @@ -6,11 +6,8 @@ * detects blockers, tracks terminal state, and accumulates cost using * the cumulative-max pattern (K004). * - * Adapted from packages/mcp-server/src/session-manager.ts with: - * - Logger integration for structured logging - * - EventEmitter for session lifecycle events - * - getAllSessions() for cross-project status (R035) - * - projectName field on ManagedSession + * Purpose: provide daemon-owned session tracking without exposing SF workflows + * through an MCP server. */ import { execSync } from "node:child_process"; @@ -45,7 +42,9 @@ const FIRE_AND_FORGET_METHODS = new Set([ ]); const TERMINAL_PREFIXES = [ + "autonomous mode stopped", "auto-mode stopped", + "autonomous mode paused", "auto-mode paused", "step-mode stopped", ]; @@ -62,7 +61,9 @@ function isBlockedNotification(event: Record): boolean { if (event.type !== "extension_ui_request" || event.method !== "notify") return false; const message = String(event.message ?? "").toLowerCase(); - return message.includes("blocked:") || message.startsWith("auto-mode paused"); + return ( + message.includes("blocked:") || message.startsWith("autonomous mode paused") + ); } function isBlockingUIRequest(event: Record): boolean { @@ -84,7 +85,7 @@ export class SessionManager extends EventEmitter { } /** - * Start a new SF auto-mode session for the given project directory. + * Start a new SF autonomous mode session for the given project directory. * * Rejects if a session already exists for this projectDir. * Creates an RpcClient, starts the process, performs the v2 init handshake, @@ -433,7 +434,7 @@ export class SessionManager extends EventEmitter { } } - // Terminal detection — auto-mode/step-mode stopped + // Terminal detection — autonomous mode/step-mode stopped if (isTerminalNotification(event as Record)) { if (isBlockedNotification(event as Record)) { session.status = "blocked"; diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index b9f8c638b..d5652f01d 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -228,7 +228,7 @@ export interface FormattedEvent { // Constants // --------------------------------------------------------------------------- -/** Maximum number of events kept in the ring buffer (larger than mcp-server's 50 — daemon forwards events to Discord) */ +/** Maximum number of events kept in the ring buffer for daemon-forwarded integrations. */ export const MAX_EVENTS = 100; /** Timeout for RpcClient initialization (ms) */ diff --git a/packages/mcp-server/.npmignore b/packages/mcp-server/.npmignore deleted file mode 100644 index 5aedf8f6e..000000000 --- a/packages/mcp-server/.npmignore +++ /dev/null @@ -1 +0,0 @@ -dist/*.test.* diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md deleted file mode 100644 index 6fb35d37c..000000000 --- a/packages/mcp-server/README.md +++ /dev/null @@ -1,251 +0,0 @@ -# @singularity-forge/mcp-server - -MCP server exposing SF orchestration tools for Claude Code, Cursor, and other MCP-compatible clients. - -Start SF auto-mode sessions, poll progress, resolve blockers, and retrieve results — all through the [Model Context Protocol](https://modelcontextprotocol.io/). - -This package now exposes two tool surfaces: - -- session/read tools for starting and inspecting SF sessions -- MCP-native interactive tools for structured user input -- headless-safe workflow tools for planning, completion, validation, reassessment, metadata persistence, and journal reads - -## Installation - -```bash -npm install @singularity-forge/mcp-server -``` - -Or with the monorepo workspace: - -```bash -# Already available as a workspace package -npx sf-mcp-server -``` - -## Configuration - -### Claude Code - -Add to your project's `.mcp.json`: - -```json -{ - "mcpServers": { - "sf": { - "command": "npx", - "args": ["sf-mcp-server"], - "env": { - "SF_CLI_PATH": "/path/to/sf" - } - } - } -} -``` - -Or if installed globally: - -```json -{ - "mcpServers": { - "sf": { - "command": "sf-mcp-server" - } - } -} -``` - -### Cursor - -Add to `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "sf": { - "command": "npx", - "args": ["sf-mcp-server"], - "env": { - "SF_CLI_PATH": "/path/to/sf" - } - } - } -} -``` - -## Tools - -### Workflow tools - -The workflow MCP surface includes: - -- `sf_decision_save` -- `sf_requirement_update` -- `sf_requirement_save` -- `sf_milestone_generate_id` -- `sf_plan_milestone` -- `sf_plan_slice` -- `sf_plan_task` -- `sf_replan_slice` -- `sf_task_complete` -- `sf_slice_complete` -- `sf_skip_slice` -- `sf_validate_milestone` -- `sf_complete_milestone` -- `sf_reassess_roadmap` -- `sf_save_gate_result` -- `sf_summary_save` -- `sf_milestone_status` -- `sf_journal_query` - -These tools use the same SF workflow handlers as the native in-process tool path wherever a shared handler exists. - -### Interactive tools - -The packaged server now exposes `ask_user_questions` through MCP form elicitation. This keeps the existing SF answer payload shape while allowing Claude Code CLI and other elicitation-capable clients to surface structured user choices. - -`secure_env_collect` is still not exposed by this package. That path needs MCP URL elicitation or an equivalent secure bridge because secrets should not flow through form elicitation. - -Current support boundary: - -- when running inside the SF monorepo checkout, the MCP server auto-discovers the shared workflow executor module -- outside the monorepo, set `SF_WORKFLOW_EXECUTORS_MODULE` to an importable `workflow-tool-executors` module path if you want the mutation tools enabled -- `ask_user_questions` requires an MCP client that supports form elicitation -- session/read tools do not depend on this bridge - -If the executor bridge cannot be loaded, workflow mutation calls will fail with a precise configuration error instead of silently degrading. - -### `sf_execute` - -Start a SF auto-mode session for a project directory. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectDir` | `string` | ✅ | Absolute path to the project directory | -| `command` | `string` | | Command to send (default: `"/sf autonomous"`) | -| `model` | `string` | | Model ID override | -| `bare` | `boolean` | | Run in bare mode (skip user config) | - -**Returns:** `{ sessionId, status: "started" }` - -### `sf_status` - -Poll the current status of a running SF session. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `sessionId` | `string` | ✅ | Session ID from `sf_execute` | - -**Returns:** - -```json -{ - "status": "running", - "progress": { "eventCount": 42, "toolCalls": 15 }, - "recentEvents": [ ... ], - "pendingBlocker": null, - "cost": { "totalCost": 0.12, "tokens": { "input": 5000, "output": 2000, "cacheRead": 1000, "cacheWrite": 500 } }, - "durationMs": 45000 -} -``` - -### `sf_result` - -Get the accumulated result of a session. Works for both running (partial) and completed sessions. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `sessionId` | `string` | ✅ | Session ID from `sf_execute` | - -**Returns:** - -```json -{ - "sessionId": "abc-123", - "projectDir": "/path/to/project", - "status": "completed", - "durationMs": 120000, - "cost": { ... }, - "recentEvents": [ ... ], - "pendingBlocker": null, - "error": null -} -``` - -### `sf_cancel` - -Cancel a running session. Aborts the current operation and stops the agent process. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `sessionId` | `string` | ✅ | Session ID from `sf_execute` | - -**Returns:** `{ cancelled: true }` - -### `sf_query` - -Query SF project state from the filesystem without an active session. Returns STATE.md, PROJECT.md, requirements, and milestone listing. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectDir` | `string` | ✅ | Absolute path to the project directory | -| `query` | `string` | ✅ | What to query (e.g. `"status"`, `"milestones"`) | - -**Returns:** - -```json -{ - "projectDir": "/path/to/project", - "state": "...", - "project": "...", - "requirements": "...", - "milestones": [ - { "id": "M001", "hasRoadmap": true, "hasSummary": false } - ] -} -``` - -### `sf_resolve_blocker` - -Resolve a pending blocker in a session by sending a response to the blocked UI request. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `sessionId` | `string` | ✅ | Session ID from `sf_execute` | -| `response` | `string` | ✅ | Response to send for the pending blocker | - -**Returns:** `{ resolved: true }` - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `SF_CLI_PATH` | Absolute path to the SF CLI binary. If not set, the server resolves `sf` via `which`. | -| `SF_WORKFLOW_EXECUTORS_MODULE` | Optional absolute path or `file:` URL for the shared SF workflow executor module used by workflow mutation tools. | - -The server also hydrates supported model-provider and tool credentials from `~/.sf/agent/auth.json` on startup. Keys saved through `/sf config` or `/sf keys` become available to the MCP server process automatically, and any explicitly-set environment variable still wins. - -## Architecture - -``` -┌─────────────────┐ stdio ┌──────────────────┐ -│ MCP Client │ ◄────────────► │ @singularity-forge/mcp-server │ -│ (Claude Code, │ JSON-RPC │ │ -│ Cursor, etc.) │ │ SessionManager │ -└─────────────────┘ │ │ │ - │ ▼ │ - │ @singularity-forge/rpc-client │ - │ │ │ - │ ▼ │ - │ SF CLI (child │ - │ process via RPC)│ - └──────────────────┘ -``` - -- **@singularity-forge/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations. -- **SessionManager** — Manages RpcClient lifecycle. One session per project directory. Tracks events in a ring buffer (last 50), detects blockers, accumulates cost. -- **@singularity-forge/rpc-client** — Low-level RPC client that spawns and communicates with the SF CLI process via JSON-RPC over stdio. - -## License - -MIT diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json deleted file mode 100644 index de413355d..000000000 --- a/packages/mcp-server/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@singularity-forge/mcp-server", - "version": "2.75.0", - "description": "MCP server exposing sf-run orchestration tools for Claude Code, Cursor, and other MCP clients", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/singularity-forge/sf-run.git", - "directory": "packages/mcp-server" - }, - "publishConfig": { - "access": "public" - }, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "bin": { - "sf-mcp-server": "./dist/cli.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run packages/mcp-server/src --root ../.. --config vitest.config.ts" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "@singularity-forge/pi-agent-core": "^2.75.0", - "@singularity-forge/rpc-client": "^2.75.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/node": "^24.12.0", - "typescript": "^5.4.0" - }, - "engines": { - "node": ">=24.15.0" - }, - "files": [ - "dist", - "!dist/**/*.test.*" - ] -} diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts deleted file mode 100644 index 798fdd3b4..000000000 --- a/packages/mcp-server/src/cli.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @singularity-forge/mcp-server CLI — stdio transport entry point. - * - * Connects the MCP server to stdin/stdout for use by Claude Code, - * Cursor, and other MCP-compatible clients. - */ - -import { createMcpServer } from "./server.js"; -import { SessionManager } from "./session-manager.js"; -import { loadStoredCredentialEnvKeys } from "./tool-credentials.js"; - -const MCP_PKG = "@modelcontextprotocol/sdk"; - -async function main(): Promise { - loadStoredCredentialEnvKeys(); - - const sessionManager = new SessionManager(); - - // Create the configured MCP server with session, interactive, read-only, - // and workflow tools. - const { server } = await createMcpServer(sessionManager); - - // Dynamic import for StdioServerTransport (same TS subpath workaround) - const { StdioServerTransport } = await import(`${MCP_PKG}/server/stdio.js`); - const transport = new StdioServerTransport(); - - // Cleanup handler — stop all sessions before exiting - let cleaningUp = false; - async function cleanup(): Promise { - if (cleaningUp) return; - cleaningUp = true; - process.stderr.write("[sf-mcp-server] Shutting down...\n"); - try { - await sessionManager.cleanup(); - } catch { - // swallow cleanup errors - } - try { - await server.close(); - } catch { - // swallow close errors - } - process.exit(0); - } - - process.on("SIGTERM", () => void cleanup()); - process.on("SIGINT", () => void cleanup()); - - // Handle stdin end — MCP client disconnected - process.stdin.on("end", () => void cleanup()); - - // Connect and start serving - try { - await server.connect(transport); - process.stderr.write("[sf-mcp-server] MCP server started on stdio\n"); - } catch (err) { - process.stderr.write( - `[sf-mcp-server] Fatal: failed to start — ${err instanceof Error ? err.message : String(err)}\n`, - ); - await sessionManager.cleanup(); - process.exit(1); - } -} - -main().catch((err) => { - process.stderr.write( - `[sf-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n`, - ); - process.exit(1); -}); diff --git a/packages/mcp-server/src/coerce-string-arrays.test.ts b/packages/mcp-server/src/coerce-string-arrays.test.ts deleted file mode 100644 index d3525855e..000000000 --- a/packages/mcp-server/src/coerce-string-arrays.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "vitest"; -import { z } from "zod"; - -import { validateToolArguments } from "../../pi-ai/src/utils/validation.ts"; -import { registerWorkflowTools } from "./workflow-tools.ts"; - -type RegisteredTool = { - name: string; - description: string; - params: Record; -}; - -function makeMockServer() { - const tools: RegisteredTool[] = []; - return { - tools, - tool( - name: string, - description: string, - params: Record, - _handler: (args: Record) => Promise, - ) { - tools.push({ name, description, params }); - }, - }; -} - -function workflowToolSchema(toolName: string): Record { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const tool = server.tools.find((candidate) => candidate.name === toolName); - assert.ok(tool, `${toolName} should be registered`); - - const schema = z.toJSONSchema( - z.object(tool.params as z.ZodRawShape), - ) as Record; - delete schema.$schema; - return schema; -} - -function makeToolCall(overrides: Record) { - return { - type: "toolCall" as const, - id: "call-1", - name: "sf_task_complete", - arguments: { - projectDir: "/tmp/sf-project", - taskId: "T01", - sliceId: "S01", - milestoneId: "M001", - oneLiner: "Completed task", - narrative: "Did the work.", - verification: "npm test", - ...overrides, - }, - }; -} - -describe("string-array schema coercion", () => { - const sfCompleteTaskTool = { - name: "sf_task_complete", - description: - "Record a completed task to the SF database and render its SUMMARY.md.", - parameters: workflowToolSchema("sf_task_complete") as any, - }; - - it("coerces a bare string keyDecisions value before validation", () => { - const args = validateToolArguments( - sfCompleteTaskTool, - makeToolCall({ keyDecisions: "single string" }), - ); - - assert.deepEqual(args.keyDecisions, ["single string"]); - }); - - it("keeps an array keyDecisions value valid", () => { - const args = validateToolArguments( - sfCompleteTaskTool, - makeToolCall({ keyDecisions: ["a", "b"] }), - ); - - assert.deepEqual(args.keyDecisions, ["a", "b"]); - }); - - it("rejects a non-string, non-array keyDecisions value", () => { - assert.throws( - () => - validateToolArguments( - sfCompleteTaskTool, - makeToolCall({ keyDecisions: 42 }), - ), - /keyDecisions: must be array/, - ); - }); - - it("allows an undefined optional keyDecisions value", () => { - const args = validateToolArguments( - sfCompleteTaskTool, - makeToolCall({ keyDecisions: undefined }), - ); - - assert.equal(args.keyDecisions, undefined); - }); -}); diff --git a/packages/mcp-server/src/env-writer.test.ts b/packages/mcp-server/src/env-writer.test.ts deleted file mode 100644 index a7a1d2af2..000000000 --- a/packages/mcp-server/src/env-writer.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -// @singularity-forge/mcp-server — Tests for env-writer utilities -// Copyright (c) 2026 Jeremy McSpadden - -import assert from "node:assert/strict"; -import { - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, it } from "vitest"; - -import { - applySecrets, - checkExistingEnvKeys, - detectDestination, - isSafeEnvVarKey, - isSupportedDeploymentEnvironment, - resolveProjectEnvFilePath, - shellEscapeSingle, - writeEnvKey, -} from "./env-writer.js"; - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), `${prefix}-`)); -} - -// --------------------------------------------------------------------------- -// checkExistingEnvKeys -// --------------------------------------------------------------------------- - -describe("checkExistingEnvKeys", () => { - it("finds key in .env file", async () => { - const tmp = makeTempDir("env-check"); - try { - const envPath = join(tmp, ".env"); - writeFileSync(envPath, "API_KEY=secret123\nOTHER=val\n"); - const result = await checkExistingEnvKeys(["API_KEY"], envPath); - assert.deepStrictEqual(result, ["API_KEY"]); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("finds key in process.env", async () => { - const tmp = makeTempDir("env-check"); - const saved = process.env.SF_MCP_TEST_KEY_1; - try { - process.env.SF_MCP_TEST_KEY_1 = "some-value"; - const envPath = join(tmp, ".env"); - const result = await checkExistingEnvKeys(["SF_MCP_TEST_KEY_1"], envPath); - assert.deepStrictEqual(result, ["SF_MCP_TEST_KEY_1"]); - } finally { - delete process.env.SF_MCP_TEST_KEY_1; - if (saved !== undefined) process.env.SF_MCP_TEST_KEY_1 = saved; - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("returns empty for missing keys", async () => { - const tmp = makeTempDir("env-check"); - try { - const envPath = join(tmp, ".env"); - writeFileSync(envPath, "OTHER=val\n"); - delete process.env.DEFINITELY_NOT_SET_MCP_XYZ; - const result = await checkExistingEnvKeys( - ["DEFINITELY_NOT_SET_MCP_XYZ"], - envPath, - ); - assert.deepStrictEqual(result, []); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("handles missing .env file gracefully", async () => { - const tmp = makeTempDir("env-check"); - try { - const envPath = join(tmp, "nonexistent.env"); - delete process.env.DEFINITELY_NOT_SET_MCP_XYZ; - const result = await checkExistingEnvKeys( - ["DEFINITELY_NOT_SET_MCP_XYZ"], - envPath, - ); - assert.deepStrictEqual(result, []); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// detectDestination -// --------------------------------------------------------------------------- - -describe("detectDestination", () => { - it("returns vercel when vercel.json exists", () => { - const tmp = makeTempDir("dest"); - try { - writeFileSync(join(tmp, "vercel.json"), "{}"); - assert.equal(detectDestination(tmp), "vercel"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("returns convex when convex/ dir exists", () => { - const tmp = makeTempDir("dest"); - try { - mkdirSync(join(tmp, "convex")); - assert.equal(detectDestination(tmp), "convex"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("returns dotenv when neither exists", () => { - const tmp = makeTempDir("dest"); - try { - assert.equal(detectDestination(tmp), "dotenv"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("vercel takes priority over convex", () => { - const tmp = makeTempDir("dest"); - try { - writeFileSync(join(tmp, "vercel.json"), "{}"); - mkdirSync(join(tmp, "convex")); - assert.equal(detectDestination(tmp), "vercel"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// writeEnvKey -// --------------------------------------------------------------------------- - -describe("writeEnvKey", () => { - it("creates .env file with new key", async () => { - const tmp = makeTempDir("write"); - try { - const envPath = join(tmp, ".env"); - await writeEnvKey(envPath, "NEW_KEY", "new-value"); - const content = readFileSync(envPath, "utf8"); - assert.ok(content.includes("NEW_KEY=new-value")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("updates existing key in-place", async () => { - const tmp = makeTempDir("write"); - try { - const envPath = join(tmp, ".env"); - writeFileSync(envPath, "EXISTING=old\nOTHER=keep\n"); - await writeEnvKey(envPath, "EXISTING", "new"); - const content = readFileSync(envPath, "utf8"); - assert.ok(content.includes("EXISTING=new")); - assert.ok(content.includes("OTHER=keep")); - assert.ok(!content.includes("old")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("escapes newlines in values", async () => { - const tmp = makeTempDir("write"); - try { - const envPath = join(tmp, ".env"); - await writeEnvKey(envPath, "MULTI", "line1\nline2"); - const content = readFileSync(envPath, "utf8"); - assert.ok(content.includes("MULTI=line1\\nline2")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("rejects non-string values", async () => { - const tmp = makeTempDir("write"); - try { - const envPath = join(tmp, ".env"); - await assert.rejects( - () => writeEnvKey(envPath, "KEY", undefined as unknown as string), - /expects a string value/, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("does not follow symlinked env files when writing", async () => { - const tmp = makeTempDir("write"); - const outside = makeTempDir("write-outside"); - try { - const outsideEnv = join(outside, ".env"); - writeFileSync(outsideEnv, "SECRET=outside\n"); - symlinkSync(outsideEnv, join(tmp, ".env")); - - await assert.rejects( - () => writeEnvKey(join(tmp, ".env"), "SECRET", "inside"), - /ELOOP|symbolic link|symlink/i, - ); - assert.equal(readFileSync(outsideEnv, "utf8"), "SECRET=outside\n"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - rmSync(outside, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// resolveProjectEnvFilePath -// --------------------------------------------------------------------------- - -describe("resolveProjectEnvFilePath", () => { - it("allows .env under the project root", () => { - const tmp = makeTempDir("env-path"); - try { - assert.equal( - resolveProjectEnvFilePath(tmp, ".env"), - join(realpathSync.native(tmp), ".env"), - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("rejects envFilePath outside the project root", () => { - const tmp = makeTempDir("env-path"); - try { - assert.throws( - () => resolveProjectEnvFilePath(tmp, "../outside.env"), - /inside the project directory/, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("rejects symlinked parent directories that escape the project root", () => { - const tmp = makeTempDir("env-path"); - const outside = makeTempDir("env-path-outside"); - try { - symlinkSync(outside, join(tmp, "linked-outside"), "dir"); - assert.throws( - () => resolveProjectEnvFilePath(tmp, "linked-outside/.env"), - /inside the project directory/, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - rmSync(outside, { recursive: true, force: true }); - } - }); - - it("rejects existing env files that are symlinks outside the project root", () => { - const tmp = makeTempDir("env-path"); - const outside = makeTempDir("env-path-outside"); - try { - writeFileSync(join(outside, ".env"), "SECRET=outside\n"); - symlinkSync(join(outside, ".env"), join(tmp, ".env")); - assert.throws( - () => resolveProjectEnvFilePath(tmp, ".env"), - /inside the project directory/, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - rmSync(outside, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// applySecrets (dotenv) -// --------------------------------------------------------------------------- - -describe("applySecrets", () => { - const savedKeys: Record = {}; - - afterEach(() => { - for (const [k, v] of Object.entries(savedKeys)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - }); - - it("writes keys to .env and hydrates process.env", async () => { - const tmp = makeTempDir("apply"); - const envPath = join(tmp, ".env"); - savedKeys.SF_APPLY_TEST_A = process.env.SF_APPLY_TEST_A; - try { - const { applied, errors } = await applySecrets( - [{ key: "SF_APPLY_TEST_A", value: "val-a" }], - "dotenv", - { envFilePath: envPath }, - ); - assert.deepStrictEqual(applied, ["SF_APPLY_TEST_A"]); - assert.deepStrictEqual(errors, []); - assert.equal(process.env.SF_APPLY_TEST_A, "val-a"); - const content = readFileSync(envPath, "utf8"); - assert.ok(content.includes("SF_APPLY_TEST_A=val-a")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("returns errors for invalid vercel environment", async () => { - const tmp = makeTempDir("apply"); - try { - const { applied, errors } = await applySecrets( - [{ key: "KEY", value: "val" }], - "vercel", - { - envFilePath: join(tmp, ".env"), - environment: "staging" as "development", - execFn: async () => ({ code: 0, stderr: "" }), - }, - ); - assert.deepStrictEqual(applied, []); - assert.ok(errors[0]?.includes("unsupported")); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); -}); - -// --------------------------------------------------------------------------- -// Validation helpers -// --------------------------------------------------------------------------- - -describe("isSafeEnvVarKey", () => { - it("accepts valid keys", () => { - assert.ok(isSafeEnvVarKey("API_KEY")); - assert.ok(isSafeEnvVarKey("_PRIVATE")); - assert.ok(isSafeEnvVarKey("key123")); - }); - - it("rejects invalid keys", () => { - assert.ok(!isSafeEnvVarKey("123BAD")); - assert.ok(!isSafeEnvVarKey("has-dash")); - assert.ok(!isSafeEnvVarKey("has space")); - assert.ok(!isSafeEnvVarKey("")); - }); -}); - -describe("isSupportedDeploymentEnvironment", () => { - it("accepts valid environments", () => { - assert.ok(isSupportedDeploymentEnvironment("development")); - assert.ok(isSupportedDeploymentEnvironment("preview")); - assert.ok(isSupportedDeploymentEnvironment("production")); - }); - - it("rejects invalid environments", () => { - assert.ok(!isSupportedDeploymentEnvironment("staging")); - assert.ok(!isSupportedDeploymentEnvironment("test")); - }); -}); - -describe("shellEscapeSingle", () => { - it("wraps in single quotes", () => { - assert.equal(shellEscapeSingle("hello"), "'hello'"); - }); - - it("escapes embedded single quotes", () => { - assert.equal(shellEscapeSingle("it's"), "'it'\\''s'"); - }); -}); diff --git a/packages/mcp-server/src/env-writer.ts b/packages/mcp-server/src/env-writer.ts deleted file mode 100644 index ff3c296c2..000000000 --- a/packages/mcp-server/src/env-writer.ts +++ /dev/null @@ -1,301 +0,0 @@ -// @singularity-forge/mcp-server — Environment variable write utilities -// Copyright (c) 2026 Jeremy McSpadden -// -// Shared helpers for writing env vars to .env files, detecting project -// destinations, and checking existing keys. Used by secure_env_collect -// MCP tool. No TUI dependencies — pure filesystem + process.env operations. - -import { - constants, - existsSync, - lstatSync, - realpathSync, - statSync, -} from "node:fs"; -import { open, readFile, rename, rm } from "node:fs/promises"; -import { - basename, - dirname, - isAbsolute, - join, - relative, - resolve, -} from "node:path"; - -// --------------------------------------------------------------------------- -// checkExistingEnvKeys -// --------------------------------------------------------------------------- - -/** - * Check which keys already exist in a .env file or process.env. - * Returns the subset of `keys` that are already set. - */ -export async function checkExistingEnvKeys( - keys: string[], - envFilePath: string, -): Promise { - let fileContent = ""; - try { - fileContent = await readFile(envFilePath, "utf8"); - } catch { - // ENOENT or other read error — proceed with empty content - } - - const existing: string[] = []; - for (const key of keys) { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(`^${escaped}\\s*=`, "m"); - if (regex.test(fileContent) || key in process.env) { - existing.push(key); - } - } - return existing; -} - -// --------------------------------------------------------------------------- -// detectDestination -// --------------------------------------------------------------------------- - -/** - * Detect the write destination based on project files in basePath. - * Priority: vercel.json → convex/ dir → fallback "dotenv". - */ -export function detectDestination( - basePath: string, -): "dotenv" | "vercel" | "convex" { - if (existsSync(resolve(basePath, "vercel.json"))) { - return "vercel"; - } - const convexPath = resolve(basePath, "convex"); - try { - if (existsSync(convexPath) && statSync(convexPath).isDirectory()) { - return "convex"; - } - } catch { - // stat error — treat as not found - } - return "dotenv"; -} - -// --------------------------------------------------------------------------- -// writeEnvKey -// --------------------------------------------------------------------------- - -/** - * Write a single key=value pair to a .env file. - * Updates existing keys in-place, appends new ones at the end. - */ -export async function writeEnvKey( - filePath: string, - key: string, - value: string, -): Promise { - if (typeof value !== "string") { - throw new TypeError( - `writeEnvKey expects a string value for key "${key}", got ${typeof value}`, - ); - } - assertWritableEnvFileTarget(filePath); - let content = ""; - try { - const handle = await open( - filePath, - constants.O_RDONLY | constants.O_NOFOLLOW, - ); - try { - content = await handle.readFile("utf8"); - } finally { - await handle.close(); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - content = ""; - } - const escaped = value - .replace(/\\/g, "\\\\") - .replace(/\n/g, "\\n") - .replace(/\r/g, ""); - const line = `${key}=${escaped}`; - const regex = new RegExp( - `^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=.*$`, - "m", - ); - if (regex.test(content)) { - content = content.replace(regex, line); - } else { - if (content.length > 0 && !content.endsWith("\n")) content += "\n"; - content += `${line}\n`; - } - const tempPath = join( - dirname(filePath), - `.${basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`, - ); - let handle: Awaited> | undefined; - try { - handle = await open( - tempPath, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, - 0o600, - ); - await handle.writeFile(content, "utf8"); - await handle.close(); - handle = undefined; - assertWritableEnvFileTarget(filePath); - await rename(tempPath, filePath); - } catch (err) { - if (handle) { - try { - await handle.close(); - } catch { - // Best-effort cleanup. - } - } - await rm(tempPath, { force: true }).catch(() => undefined); - throw err; - } -} - -function assertWritableEnvFileTarget(filePath: string): void { - try { - if (lstatSync(filePath).isSymbolicLink()) { - throw new Error("Refusing to write symlinked env file"); - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw err; - } -} - -// --------------------------------------------------------------------------- -// Validation helpers -// --------------------------------------------------------------------------- - -export function isSafeEnvVarKey(key: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key); -} - -export function isSupportedDeploymentEnvironment(env: string): boolean { - return env === "development" || env === "preview" || env === "production"; -} - -function isWithinProjectRoot( - projectRoot: string, - candidatePath: string, -): boolean { - const rel = relative(projectRoot, candidatePath); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} - -export function resolveProjectEnvFilePath( - projectDir: string, - envFilePath = ".env", -): string { - const projectRoot = realpathSync.native(resolve(projectDir)); - const candidate = resolve(projectRoot, envFilePath); - if (!isWithinProjectRoot(projectRoot, candidate)) { - throw new Error("envFilePath must resolve inside the project directory"); - } - if (existsSync(candidate)) { - const targetRealPath = realpathSync.native(candidate); - if (isWithinProjectRoot(projectRoot, targetRealPath)) { - return candidate; - } - throw new Error("envFilePath must resolve inside the project directory"); - } - const candidateParent = dirname(candidate); - const parentRealPath = realpathSync.native(candidateParent); - if (isWithinProjectRoot(projectRoot, parentRealPath)) { - return candidate; - } - throw new Error("envFilePath must resolve inside the project directory"); -} - -// --------------------------------------------------------------------------- -// Shell helpers (for vercel/convex CLI) -// --------------------------------------------------------------------------- - -export function shellEscapeSingle(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -// --------------------------------------------------------------------------- -// applySecrets -// --------------------------------------------------------------------------- - -interface ApplyResult { - applied: string[]; - errors: string[]; -} - -/** - * Apply collected secrets to the target destination. - * Dotenv writes are handled directly; vercel/convex shell out via execFn. - */ -export async function applySecrets( - provided: Array<{ key: string; value: string }>, - destination: "dotenv" | "vercel" | "convex", - opts: { - envFilePath: string; - environment?: string; - execFn?: ( - cmd: string, - args: string[], - ) => Promise<{ code: number; stderr: string }>; - }, -): Promise { - const applied: string[] = []; - const errors: string[] = []; - - if (destination === "dotenv") { - for (const { key, value } of provided) { - try { - await writeEnvKey(opts.envFilePath, key, value); - applied.push(key); - // Hydrate process.env so the current session sees the new value - process.env[key] = value; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`${key}: ${msg}`); - } - } - } - - if ((destination === "vercel" || destination === "convex") && opts.execFn) { - const env = opts.environment ?? "development"; - if (!isSupportedDeploymentEnvironment(env)) { - errors.push(`environment: unsupported target environment "${env}"`); - return { applied, errors }; - } - for (const { key, value } of provided) { - if (!isSafeEnvVarKey(key)) { - errors.push(`${key}: invalid environment variable name`); - continue; - } - const cmd = - destination === "vercel" - ? `printf %s ${shellEscapeSingle(value)} | vercel env add ${key} ${env}` - : ""; - try { - const result = - destination === "vercel" - ? await opts.execFn("sh", ["-c", cmd]) - : await opts.execFn("npx", ["convex", "env", "set", key, value]); - if (result.code !== 0) { - errors.push(`${key}: ${result.stderr.slice(0, 200)}`); - } else { - applied.push(key); - process.env[key] = value; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`${key}: ${msg}`); - } - } - } - - return { applied, errors }; -} diff --git a/packages/mcp-server/src/import-candidates.test.ts b/packages/mcp-server/src/import-candidates.test.ts deleted file mode 100644 index 81265d18a..000000000 --- a/packages/mcp-server/src/import-candidates.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// SF — Regression tests for importLocalModule candidate resolution (#3954) - -import assert from "node:assert/strict"; -import { describe, it } from "vitest"; - -import { _buildImportCandidates } from "./workflow-tools.js"; - -describe("_buildImportCandidates", () => { - it("includes dist/ fallback for src/ paths", () => { - const candidates = _buildImportCandidates( - "../../../src/resources/extensions/sf/db-writer.js", - ); - assert.ok( - candidates.some((c) => - c.includes("/dist/resources/extensions/sf/db-writer.js"), - ), - "should include dist/ swapped candidate", - ); - }); - - it("includes src/ fallback for dist/ paths", () => { - const candidates = _buildImportCandidates( - "../../../dist/resources/extensions/sf/db-writer.js", - ); - assert.ok( - candidates.some((c) => - c.includes("/src/resources/extensions/sf/db-writer.js"), - ), - "should include src/ swapped candidate", - ); - }); - - it("includes .ts variants for .js paths", () => { - const candidates = _buildImportCandidates( - "../../../src/resources/extensions/sf/db-writer.js", - ); - assert.ok( - candidates.some((c) => c.endsWith("db-writer.ts") && c.includes("/src/")), - "should include .ts variant for original src/ path", - ); - assert.ok( - candidates.some( - (c) => c.endsWith("db-writer.ts") && c.includes("/dist/"), - ), - "should include .ts variant for swapped dist/ path", - ); - }); - - it("returns original path first", () => { - const input = "../../../src/resources/extensions/sf/db-writer.js"; - const candidates = _buildImportCandidates(input); - assert.equal( - candidates[0], - input, - "first candidate should be the original path", - ); - }); - - it("handles paths without src/ or dist/ gracefully", () => { - const candidates = _buildImportCandidates("./local-module.js"); - assert.equal( - candidates.length, - 2, - "should have original + .ts variant only", - ); - assert.equal(candidates[0], "./local-module.js"); - assert.equal(candidates[1], "./local-module.ts"); - }); -}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts deleted file mode 100644 index f0dc0b46e..000000000 --- a/packages/mcp-server/src/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @singularity-forge/mcp-server — MCP server for SF orchestration and project state. - */ - -// Path resolution utilities -export { resolveSFRoot } from "@singularity-forge/pi-agent-core"; -export type { CaptureEntry, CapturesResult } from "./readers/captures.js"; -export { readCaptures } from "./readers/captures.js"; -export type { DoctorIssue, DoctorResult } from "./readers/doctor-lite.js"; -export { runDoctorLite } from "./readers/doctor-lite.js"; -export type { - ConfidenceTier, - EdgeType, - GraphDiffResult, - GraphEdge, - GraphNode, - GraphQueryResult, - GraphStatusResult, - KnowledgeGraph, - NodeType, -} from "./readers/graph.js"; -export { - buildGraph, - graphDiff, - graphQuery, - graphStatus, - writeGraph, - writeSnapshot, -} from "./readers/graph.js"; -export type { KnowledgeEntry, KnowledgeResult } from "./readers/knowledge.js"; -export { readKnowledge } from "./readers/knowledge.js"; -export type { HistoryResult, MetricsUnit } from "./readers/metrics.js"; -export { readHistory } from "./readers/metrics.js"; -export type { - MilestoneInfo, - RoadmapResult, - SliceInfo, - TaskInfo, -} from "./readers/roadmap.js"; -export { readRoadmap } from "./readers/roadmap.js"; -export type { ProgressResult } from "./readers/state.js"; -// Read-only state readers (usable without a running session) -export { readProgress } from "./readers/state.js"; -export { createMcpServer } from "./server.js"; -export { SessionManager } from "./session-manager.js"; -export type { - CostAccumulator, - ExecuteOptions, - ManagedSession, - PendingBlocker, - SessionStatus, -} from "./types.js"; -export { INIT_TIMEOUT_MS, MAX_EVENTS } from "./types.js"; diff --git a/packages/mcp-server/src/mcp-server.test.ts b/packages/mcp-server/src/mcp-server.test.ts deleted file mode 100644 index ace4f71b6..000000000 --- a/packages/mcp-server/src/mcp-server.test.ts +++ /dev/null @@ -1,783 +0,0 @@ -/** - * @singularity-forge/mcp-server — Integration and unit tests. - * - * Strategy: We cannot mock @singularity-forge/rpc-client at the module level without - * --experimental-test-module-mocks. Instead we test by: - * - * 1. Subclassing SessionManager to inject a mock client factory - * 2. Testing event handling, state transitions, and error paths - * 3. Testing tool registration via createMcpServer - * 4. Testing CLI path resolution via static method - */ - -import assert from "node:assert/strict"; -import { resolve } from "node:path"; -import { afterEach, beforeEach, describe, it } from "vitest"; -import { - buildAskUserQuestionsElicitRequest, - createMcpServer, - formatAskUserQuestionsElicitResult, -} from "./server.js"; -import { SessionManager } from "./session-manager.js"; -import type { ManagedSession } from "./types.js"; -import { MAX_EVENTS } from "./types.js"; - -// --------------------------------------------------------------------------- -// Mock RpcClient (duck-typed to match RpcClient interface) -// --------------------------------------------------------------------------- - -class MockRpcClient { - started = false; - stopped = false; - aborted = false; - prompted: string[] = []; - private eventListeners: Array<(event: Record) => void> = []; - uiResponses: Array<{ requestId: string; response: Record }> = - []; - - /** Control — set to make start() reject */ - startError: Error | null = null; - /** Control — set to make init() reject */ - initError: Error | null = null; - /** Control — override sessionId from init */ - initSessionId = "mock-session-001"; - - cwd: string; - args: string[]; - - constructor(options?: Record) { - this.cwd = (options?.cwd as string) ?? ""; - this.args = (options?.args as string[]) ?? []; - } - - async start(): Promise { - if (this.startError) throw this.startError; - this.started = true; - } - - async stop(): Promise { - this.stopped = true; - } - - async init(): Promise<{ sessionId: string; version: string }> { - if (this.initError) throw this.initError; - return { sessionId: this.initSessionId, version: "2.51.0" }; - } - - onEvent(listener: (event: Record) => void): () => void { - this.eventListeners.push(listener); - return () => { - const idx = this.eventListeners.indexOf(listener); - if (idx >= 0) this.eventListeners.splice(idx, 1); - }; - } - - async prompt(message: string): Promise { - this.prompted.push(message); - } - - async abort(): Promise { - this.aborted = true; - } - - sendUIResponse(requestId: string, response: Record): void { - this.uiResponses.push({ requestId, response }); - } - - /** Test helper — emit an event to all listeners */ - emitEvent(event: Record): void { - for (const listener of this.eventListeners) { - listener(event); - } - } -} - -// --------------------------------------------------------------------------- -// TestableSessionManager — injects mock clients without module mocking -// --------------------------------------------------------------------------- - -/** - * Subclass that overrides startSession to use MockRpcClient instead of the - * real RpcClient. We directly construct the session object, mirroring the - * parent's logic but with our mock. - */ -class TestableSessionManager extends SessionManager { - /** The last mock client created */ - lastClient: MockRpcClient | null = null; - /** All mock clients */ - allClients: MockRpcClient[] = []; - /** Counter for unique session IDs across multiple sessions */ - private sessionCounter = 0; - /** Control: set to make startSession fail during init */ - nextInitError: Error | null = null; - /** Control: set to make startSession fail during start */ - nextStartError: Error | null = null; - - override async startSession( - projectDir: string, - options: { - cliPath?: string; - command?: string; - model?: string; - bare?: boolean; - } = {}, - ): Promise { - if (!projectDir || projectDir.trim() === "") { - throw new Error("projectDir is required and cannot be empty"); - } - - const resolvedDir = resolve(projectDir); - - // Check duplicate via getSessionByDir - const existing = this.getSessionByDir(resolvedDir); - if (existing) { - throw new Error( - `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`, - ); - } - - const client = new MockRpcClient({ cwd: resolvedDir, args: [] }); - if (this.nextStartError) { - client.startError = this.nextStartError; - this.nextStartError = null; - } - if (this.nextInitError) { - client.initError = this.nextInitError; - this.nextInitError = null; - } - - this.sessionCounter++; - client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, "0")}`; - this.lastClient = client; - this.allClients.push(client); - - // Create the session shell - const session: ManagedSession = { - sessionId: "", - projectDir: resolvedDir, - status: "starting", - client: client as any, // duck-typed mock - events: [], - pendingBlocker: null, - cost: { - totalCost: 0, - tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - startTime: Date.now(), - }; - - // Insert into internal sessions map — access via protected method - this._putSession(resolvedDir, session); - - try { - await client.start(); - - const initResult = await client.init(); - session.sessionId = initResult.sessionId; - session.status = "running"; - - // Wire event tracking using the same handleEvent logic as parent - session.unsubscribe = client.onEvent((event: Record) => { - this._handleEvent(session, event); - }); - - // Kick off autonomous mode - const command = options.command ?? "/sf autonomous"; - await client.prompt(command); - - return session.sessionId; - } catch (err) { - session.status = "error"; - session.error = err instanceof Error ? err.message : String(err); - try { - await client.stop(); - } catch { - /* swallow */ - } - throw new Error( - `Failed to start session for ${resolvedDir}: ${session.error}`, - ); - } - } - - /** Expose internal session map insertion for testing */ - _putSession(key: string, session: ManagedSession): void { - // Access the private sessions map via any cast - (this as any).sessions.set(key, session); - } - - /** Expose handleEvent for testing */ - _handleEvent(session: ManagedSession, event: Record): void { - (this as any).handleEvent(session, event); - } -} - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -let allManagers: TestableSessionManager[] = []; - -function createManager(): TestableSessionManager { - const mgr = new TestableSessionManager(); - allManagers.push(mgr); - return mgr; -} - -// --------------------------------------------------------------------------- -// SessionManager unit tests -// --------------------------------------------------------------------------- - -describe("SessionManager", () => { - let sm: TestableSessionManager; - - beforeEach(() => { - sm = createManager(); - }); - - afterEach(async () => { - for (const mgr of allManagers) { - await mgr.cleanup(); - } - allManagers = []; - }); - - it("startSession creates session and returns sessionId", async () => { - const sessionId = await sm.startSession("/tmp/test-project", { - cliPath: "/usr/bin/sf", - }); - assert.equal(sessionId, "mock-session-001"); - - const session = sm.getSession(sessionId); - assert.ok(session); - assert.equal(session.status, "running"); - assert.equal(session.projectDir, resolve("/tmp/test-project")); - }); - - it("startSession sends /sf autonomous by default", async () => { - await sm.startSession("/tmp/test-prompt", { cliPath: "/usr/bin/sf" }); - assert.ok(sm.lastClient); - assert.deepEqual(sm.lastClient.prompted, ["/sf autonomous"]); - }); - - it("startSession sends custom command when provided", async () => { - await sm.startSession("/tmp/test-cmd", { - cliPath: "/usr/bin/sf", - command: "/sf auto --resume", - }); - assert.ok(sm.lastClient); - assert.deepEqual(sm.lastClient.prompted, ["/sf auto --resume"]); - }); - - it("startSession rejects duplicate projectDir", async () => { - await sm.startSession("/tmp/dup-test", { cliPath: "/usr/bin/sf" }); - await assert.rejects( - () => sm.startSession("/tmp/dup-test", { cliPath: "/usr/bin/sf" }), - (err: Error) => { - assert.ok(err.message.includes("Session already active")); - return true; - }, - ); - }); - - it("startSession rejects empty projectDir", async () => { - await assert.rejects( - () => sm.startSession("", { cliPath: "/usr/bin/sf" }), - (err: Error) => { - assert.ok(err.message.includes("projectDir is required")); - return true; - }, - ); - }); - - it("startSession sets error status on start() failure", async () => { - sm.nextStartError = new Error("spawn failed"); - - await assert.rejects( - () => sm.startSession("/tmp/fail-start", { cliPath: "/usr/bin/sf" }), - (err: Error) => { - assert.ok(err.message.includes("Failed to start session")); - assert.ok(err.message.includes("spawn failed")); - return true; - }, - ); - }); - - it("startSession sets error status on init() failure", async () => { - sm.nextInitError = new Error("handshake failed"); - - await assert.rejects( - () => sm.startSession("/tmp/fail-init", { cliPath: "/usr/bin/sf" }), - (err: Error) => { - assert.ok(err.message.includes("Failed to start session")); - assert.ok(err.message.includes("handshake failed")); - return true; - }, - ); - }); - - it("getSession returns undefined for unknown sessionId", () => { - const result = sm.getSession("nonexistent-id"); - assert.equal(result, undefined); - }); - - it("getSessionByDir returns session for known dir", async () => { - await sm.startSession("/tmp/by-dir", { cliPath: "/usr/bin/sf" }); - const session = sm.getSessionByDir("/tmp/by-dir"); - assert.ok(session); - assert.equal(session.sessionId, "mock-session-001"); - }); - - it("resolveBlocker errors when no pending blocker", async () => { - const sessionId = await sm.startSession("/tmp/no-blocker", { - cliPath: "/usr/bin/sf", - }); - await assert.rejects( - () => sm.resolveBlocker(sessionId, "some response"), - (err: Error) => { - assert.ok(err.message.includes("No pending blocker")); - return true; - }, - ); - }); - - it("resolveBlocker errors for unknown session", async () => { - await assert.rejects( - () => sm.resolveBlocker("unknown-session", "some response"), - (err: Error) => { - assert.ok(err.message.includes("Session not found")); - return true; - }, - ); - }); - - it("resolveBlocker clears pendingBlocker and sends UI response", async () => { - const sessionId = await sm.startSession("/tmp/blocker-resolve", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - // Simulate a blocking UI request event - client.emitEvent({ - type: "extension_ui_request", - id: "req-42", - method: "select", - title: "Pick an option", - }); - - const session = sm.getSession(sessionId)!; - assert.ok(session.pendingBlocker); - assert.equal(session.status, "blocked"); - - // Resolve the blocker - await sm.resolveBlocker(sessionId, "option-a"); - - assert.equal(session.pendingBlocker, null); - assert.equal(session.status, "running"); - assert.equal(client.uiResponses.length, 1); - assert.equal(client.uiResponses[0].requestId, "req-42"); - }); - - it("cancelSession calls abort + stop on client", async () => { - const sessionId = await sm.startSession("/tmp/cancel-test", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - await sm.cancelSession(sessionId); - - assert.ok(client.aborted); - assert.ok(client.stopped); - - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "cancelled"); - }); - - it("cancelSession errors for unknown session", async () => { - await assert.rejects( - () => sm.cancelSession("unknown"), - (err: Error) => { - assert.ok(err.message.includes("Session not found")); - return true; - }, - ); - }); - - it("cleanup stops all active sessions", async () => { - await sm.startSession("/tmp/cleanup-1", { cliPath: "/usr/bin/sf" }); - await sm.startSession("/tmp/cleanup-2", { cliPath: "/usr/bin/sf" }); - - assert.equal(sm.allClients.length, 2); - - await sm.cleanup(); - - for (const client of sm.allClients) { - assert.ok(client.stopped, "Client should be stopped after cleanup"); - } - }); - - it("event ring buffer caps at MAX_EVENTS", async () => { - const sessionId = await sm.startSession("/tmp/ring-buffer", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - for (let i = 0; i < MAX_EVENTS + 20; i++) { - client.emitEvent({ type: "tool_use", index: i }); - } - - const session = sm.getSession(sessionId)!; - assert.equal(session.events.length, MAX_EVENTS); - // Oldest events trimmed — first event index should be 20 - assert.equal((session.events[0] as Record).index, 20); - }); - - it("blocker detection: non-fire-and-forget extension_ui_request sets pendingBlocker", async () => { - const sessionId = await sm.startSession("/tmp/blocker-detect", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - // 'select' is not in FIRE_AND_FORGET_METHODS - client.emitEvent({ - type: "extension_ui_request", - id: "req-99", - method: "select", - title: "Choose wisely", - }); - - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "blocked"); - assert.ok(session.pendingBlocker); - assert.equal(session.pendingBlocker.id, "req-99"); - assert.equal(session.pendingBlocker.method, "select"); - }); - - it("fire-and-forget methods do not set pendingBlocker", async () => { - const sessionId = await sm.startSession("/tmp/fire-forget", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - // 'notify' is fire-and-forget — on its own (no terminal prefix) should not block - client.emitEvent({ - type: "extension_ui_request", - id: "req-100", - method: "notify", - message: "Just a notification", - }); - - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "running"); - assert.equal(session.pendingBlocker, null); - }); - - it("terminal detection: auto-mode stopped sets status to completed", async () => { - const sessionId = await sm.startSession("/tmp/terminal", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - client.emitEvent({ - type: "extension_ui_request", - method: "notify", - message: "Auto-mode stopped — all tasks complete", - id: "term-1", - }); - - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "completed"); - }); - - it("terminal detection with blocked: message sets status to blocked", async () => { - const sessionId = await sm.startSession("/tmp/terminal-blocked", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - client.emitEvent({ - type: "extension_ui_request", - method: "notify", - message: "Auto-mode stopped — blocked: needs user input", - id: "block-1", - }); - - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "blocked"); - assert.ok(session.pendingBlocker); - }); - - it("cost tracking: cumulative-max from cost_update events", async () => { - const sessionId = await sm.startSession("/tmp/cost-track", { - cliPath: "/usr/bin/sf", - }); - const client = sm.lastClient!; - - client.emitEvent({ - type: "cost_update", - cumulativeCost: 0.05, - tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 }, - }); - - client.emitEvent({ - type: "cost_update", - cumulativeCost: 0.12, - tokens: { input: 2500, output: 800, cacheRead: 150, cacheWrite: 300 }, - }); - - const session = sm.getSession(sessionId)!; - assert.equal(session.cost.totalCost, 0.12); - assert.equal(session.cost.tokens.input, 2500); - assert.equal(session.cost.tokens.output, 800); - assert.equal(session.cost.tokens.cacheRead, 200); // First was higher - assert.equal(session.cost.tokens.cacheWrite, 300); // Second was higher - }); - - it("getResult returns HeadlessJsonResult-shaped object", async () => { - const sessionId = await sm.startSession("/tmp/result-shape", { - cliPath: "/usr/bin/sf", - }); - const result = sm.getResult(sessionId); - - assert.equal(result.sessionId, sessionId); - assert.equal(result.projectDir, resolve("/tmp/result-shape")); - assert.equal(result.status, "running"); - assert.equal(typeof result.durationMs, "number"); - assert.ok(result.cost); - assert.ok(Array.isArray(result.recentEvents)); - assert.equal(result.pendingBlocker, null); - assert.equal(result.error, null); - }); - - it("getResult errors for unknown session", () => { - assert.throws( - () => sm.getResult("unknown"), - (err: Error) => { - assert.ok(err.message.includes("Session not found")); - return true; - }, - ); - }); -}); - -// --------------------------------------------------------------------------- -// CLI path resolution tests -// --------------------------------------------------------------------------- - -describe("SessionManager.resolveCLIPath", () => { - const originalSfPath = process.env["SF_CLI_PATH"]; - const originalPath = process.env["PATH"]; - - afterEach(() => { - if (originalSfPath !== undefined) { - process.env["SF_CLI_PATH"] = originalSfPath; - } else { - delete process.env["SF_CLI_PATH"]; - } - if (originalPath !== undefined) { - process.env["PATH"] = originalPath; - } - }); - - it("SF_CLI_PATH env var takes precedence", () => { - process.env["SF_CLI_PATH"] = "/custom/path/to/sf"; - const result = SessionManager.resolveCLIPath(); - assert.equal(result, resolve("/custom/path/to/sf")); - }); - - it("throws when SF_CLI_PATH not set and which fails", () => { - delete process.env["SF_CLI_PATH"]; - process.env["PATH"] = "/nonexistent"; - assert.throws( - () => SessionManager.resolveCLIPath(), - (err: Error) => { - assert.ok(err.message.includes("Cannot find SF CLI")); - return true; - }, - ); - }); -}); - -// --------------------------------------------------------------------------- -// Tool registration tests (via createMcpServer) -// --------------------------------------------------------------------------- - -describe("createMcpServer tool registration", () => { - let sm: TestableSessionManager; - - beforeEach(() => { - sm = createManager(); - }); - - afterEach(async () => { - for (const mgr of allManagers) { - await mgr.cleanup(); - } - allManagers = []; - }); - - it("creates server successfully with all required methods", async () => { - const { server } = await createMcpServer(sm); - assert.ok(server); - assert.ok(server.server); - assert.equal(typeof server.server.elicitInput, "function"); - assert.ok(typeof server.connect === "function"); - assert.ok(typeof server.close === "function"); - }); - - it("sf_execute flow returns sessionId on success", async () => { - const sessionId = await sm.startSession("/tmp/tool-exec", { - cliPath: "/usr/bin/sf", - }); - assert.equal(typeof sessionId, "string"); - assert.ok(sessionId.length > 0); - }); - - it("sf_status flow returns correct shape", async () => { - const sessionId = await sm.startSession("/tmp/tool-status", { - cliPath: "/usr/bin/sf", - }); - const session = sm.getSession(sessionId)!; - - assert.equal(typeof session.status, "string"); - assert.ok(Array.isArray(session.events)); - assert.ok(session.cost); - assert.equal(typeof session.startTime, "number"); - }); - - it("sf_resolve_blocker flow returns error when no blocker", async () => { - const sessionId = await sm.startSession("/tmp/tool-resolve", { - cliPath: "/usr/bin/sf", - }); - await assert.rejects( - () => sm.resolveBlocker(sessionId, "fix"), - (err: Error) => { - assert.ok(err.message.includes("No pending blocker")); - return true; - }, - ); - }); - - it("sf_result flow returns HeadlessJsonResult shape", async () => { - const sessionId = await sm.startSession("/tmp/tool-result", { - cliPath: "/usr/bin/sf", - }); - const result = sm.getResult(sessionId); - - assert.ok("sessionId" in result); - assert.ok("projectDir" in result); - assert.ok("status" in result); - assert.ok("durationMs" in result); - assert.ok("cost" in result); - assert.ok("recentEvents" in result); - assert.ok("pendingBlocker" in result); - assert.ok("error" in result); - }); - - it("sf_cancel flow marks session as cancelled", async () => { - const sessionId = await sm.startSession("/tmp/tool-cancel", { - cliPath: "/usr/bin/sf", - }); - await sm.cancelSession(sessionId); - const session = sm.getSession(sessionId)!; - assert.equal(session.status, "cancelled"); - }); - - it("buildAskUserQuestionsElicitRequest adds None of the above note field for single-select questions", () => { - const request = buildAskUserQuestionsElicitRequest([ - { - id: "depth_verification_M001", - header: "Depth Check", - question: "Did I capture the depth right?", - options: [ - { - label: "Yes, you got it (Recommended)", - description: "Continue with the current summary.", - }, - { - label: "Not quite", - description: "I need to clarify the depth further.", - }, - ], - }, - { - id: "focus_areas", - header: "Focus", - question: "Which areas matter most?", - allowMultiple: true, - options: [ - { label: "Frontend", description: "Prioritize the UI." }, - { label: "Backend", description: "Prioritize server logic." }, - ], - }, - ]); - - assert.equal(request.mode, "form"); - assert.deepEqual(request.requestedSchema.required, [ - "depth_verification_M001", - "focus_areas", - ]); - assert.ok(request.requestedSchema.properties["depth_verification_M001"]); - assert.ok( - request.requestedSchema.properties["depth_verification_M001__note"], - ); - assert.ok(!request.requestedSchema.properties["focus_areas__note"]); - }); - - it("formatAskUserQuestionsElicitResult preserves the existing answers JSON shape", () => { - const result = formatAskUserQuestionsElicitResult( - [ - { - id: "depth_verification_M001", - header: "Depth Check", - question: "Did I capture the depth right?", - options: [ - { - label: "Yes, you got it (Recommended)", - description: "Continue with the current summary.", - }, - { - label: "Not quite", - description: "I need to clarify the depth further.", - }, - ], - }, - { - id: "focus_areas", - header: "Focus", - question: "Which areas matter most?", - allowMultiple: true, - options: [ - { label: "Frontend", description: "Prioritize the UI." }, - { label: "Backend", description: "Prioritize server logic." }, - ], - }, - ], - { - action: "accept", - content: { - depth_verification_M001: "None of the above", - depth_verification_M001__note: "Need more implementation detail.", - focus_areas: ["Frontend", "Backend"], - }, - }, - ); - - assert.equal( - result, - JSON.stringify({ - answers: { - depth_verification_M001: { - answers: [ - "None of the above", - "user_note: Need more implementation detail.", - ], - }, - focus_areas: { - answers: ["Frontend", "Backend"], - }, - }, - }), - ); - }); -}); diff --git a/packages/mcp-server/src/readers/captures.ts b/packages/mcp-server/src/readers/captures.ts deleted file mode 100644 index 7c9b87e7b..000000000 --- a/packages/mcp-server/src/readers/captures.ts +++ /dev/null @@ -1,140 +0,0 @@ -// SF MCP Server — captures reader -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync, readFileSync } from "node:fs"; -import { resolveRootFile, resolveSFRoot } from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type CaptureStatus = "pending" | "triaged" | "resolved"; -export type CaptureClassification = - | "quick-task" - | "inject" - | "defer" - | "replan" - | "note" - | "stop" - | "backtrack"; - -export interface CaptureEntry { - id: string; - text: string; - timestamp: string; - status: CaptureStatus; - classification: CaptureClassification | null; - resolution: string | null; - rationale: string | null; - resolvedAt: string | null; - milestone: string | null; - executed: string | null; -} - -export interface CapturesResult { - captures: CaptureEntry[]; - counts: { - total: number; - pending: number; - resolved: number; - actionable: number; - }; -} - -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - -function parseCapturesMarkdown(content: string): CaptureEntry[] { - const entries: CaptureEntry[] = []; - - // Split on H3 headers: ### CAP-xxxxxxxx - const sections = content.split(/(?=^### CAP-)/m); - - for (const section of sections) { - const idMatch = section.match(/^### (CAP-[\da-f]+)/); - if (!idMatch) continue; - - const id = idMatch[1]; - const field = (label: string): string | null => { - const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, "i"); - const m = section.match(re); - return m ? m[1].trim() : null; - }; - - const status = ( - field("Status") ?? "pending" - ).toLowerCase() as CaptureStatus; - const classification = field( - "Classification", - ) as CaptureClassification | null; - - entries.push({ - id, - text: field("Text") ?? "", - timestamp: field("Captured") ?? "", - status, - classification, - resolution: field("Resolution"), - rationale: field("Rationale"), - resolvedAt: field("Resolved"), - milestone: field("Milestone"), - executed: field("Executed"), - }); - } - - return entries; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -const ACTIONABLE_CLASSIFICATIONS = new Set([ - "quick-task", - "inject", - "replan", -]); - -export function readCaptures( - projectDir: string, - filter: "all" | "pending" | "actionable" = "all", -): CapturesResult { - const sf = resolveSFRoot(projectDir); - const capturesPath = resolveRootFile(sf, "CAPTURES.md"); - - if (!existsSync(capturesPath)) { - return { - captures: [], - counts: { total: 0, pending: 0, resolved: 0, actionable: 0 }, - }; - } - - const content = readFileSync(capturesPath, "utf-8"); - let captures = parseCapturesMarkdown(content); - - // Compute counts before filtering - const counts = { - total: captures.length, - pending: captures.filter((c) => c.status === "pending").length, - resolved: captures.filter((c) => c.status === "resolved").length, - actionable: captures.filter( - (c) => - c.classification !== null && - ACTIONABLE_CLASSIFICATIONS.has(c.classification), - ).length, - }; - - // Apply filter - if (filter === "pending") { - captures = captures.filter((c) => c.status === "pending"); - } else if (filter === "actionable") { - captures = captures.filter( - (c) => - c.classification !== null && - ACTIONABLE_CLASSIFICATIONS.has(c.classification), - ); - } - - return { captures, counts }; -} diff --git a/packages/mcp-server/src/readers/doctor-lite.ts b/packages/mcp-server/src/readers/doctor-lite.ts deleted file mode 100644 index 8cf515950..000000000 --- a/packages/mcp-server/src/readers/doctor-lite.ts +++ /dev/null @@ -1,237 +0,0 @@ -// SF MCP Server — lightweight structural health checks -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync } from "node:fs"; -import { - findMilestoneIds, - findSliceIds, - findTaskFiles, - resolveMilestoneDir, - resolveMilestoneFile, - resolveRootFile, - resolveSFRoot, - resolveSliceFile, -} from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type Severity = "info" | "warning" | "error"; - -export interface DoctorIssue { - severity: Severity; - code: string; - scope: "project" | "milestone" | "slice" | "task"; - unitId: string; - message: string; - file?: string; -} - -export interface DoctorResult { - ok: boolean; - issues: DoctorIssue[]; - counts: { error: number; warning: number; info: number }; -} - -// --------------------------------------------------------------------------- -// Check implementations -// --------------------------------------------------------------------------- - -function checkProjectLevel(sfRoot: string, issues: DoctorIssue[]): void { - // PROJECT.md should exist - const projectPath = resolveRootFile(sfRoot, "PROJECT.md"); - if (!existsSync(projectPath)) { - issues.push({ - severity: "warning", - code: "missing_project_md", - scope: "project", - unitId: "", - message: "PROJECT.md is missing — project lacks a description", - file: projectPath, - }); - } - - // STATE.md should exist if milestones exist - const milestones = findMilestoneIds(sfRoot); - if (milestones.length > 0) { - const statePath = resolveRootFile(sfRoot, "STATE.md"); - if (!existsSync(statePath)) { - issues.push({ - severity: "warning", - code: "missing_state_md", - scope: "project", - unitId: "", - message: "STATE.md is missing — run /sf status to regenerate", - file: statePath, - }); - } - } -} - -function checkMilestoneLevel( - sfRoot: string, - mid: string, - issues: DoctorIssue[], -): void { - const mDir = resolveMilestoneDir(sfRoot, mid); - if (!mDir) { - issues.push({ - severity: "error", - code: "missing_milestone_dir", - scope: "milestone", - unitId: mid, - message: `Milestone directory for ${mid} not found`, - }); - return; - } - - // CONTEXT.md should exist - const ctxPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT"); - if (!ctxPath || !existsSync(ctxPath)) { - // Check for draft - const draftPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT-DRAFT"); - if (!draftPath || !existsSync(draftPath)) { - issues.push({ - severity: "warning", - code: "missing_context", - scope: "milestone", - unitId: mid, - message: `${mid} has no CONTEXT.md — milestone lacks defined scope`, - }); - } - } - - // ROADMAP.md should exist if slices exist - const sliceIds = findSliceIds(sfRoot, mid); - if (sliceIds.length > 0) { - const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) { - issues.push({ - severity: "warning", - code: "missing_roadmap", - scope: "milestone", - unitId: mid, - message: `${mid} has ${sliceIds.length} slices but no ROADMAP.md`, - }); - } - } - - // Check if all slices done but no SUMMARY - if (sliceIds.length > 0) { - const allDone = sliceIds.every((sid) => { - const tasks = findTaskFiles(sfRoot, mid, sid); - return tasks.length > 0 && tasks.every((t) => t.hasSummary); - }); - const summaryPath = resolveMilestoneFile(sfRoot, mid, "SUMMARY"); - if (allDone && (!summaryPath || !existsSync(summaryPath))) { - issues.push({ - severity: "error", - code: "all_slices_done_missing_summary", - scope: "milestone", - unitId: mid, - message: `${mid} has all slices completed but no SUMMARY.md`, - }); - } - } -} - -function checkSliceLevel( - sfRoot: string, - mid: string, - sid: string, - issues: DoctorIssue[], -): void { - const unitId = `${mid}/${sid}`; - - // PLAN.md should exist - const planPath = resolveSliceFile(sfRoot, mid, sid, "PLAN"); - if (!planPath || !existsSync(planPath)) { - issues.push({ - severity: "error", - code: "missing_slice_plan", - scope: "slice", - unitId, - message: `${unitId} has no PLAN.md`, - }); - } - - // Tasks should have plans - const tasks = findTaskFiles(sfRoot, mid, sid); - for (const task of tasks) { - const taskUnitId = `${unitId}/${task.id}`; - if (!task.hasPlan) { - issues.push({ - severity: "warning", - code: "missing_task_plan", - scope: "task", - unitId: taskUnitId, - message: `${taskUnitId} has a summary but no plan file`, - }); - } - } - - // Check for empty slice (directory exists but no tasks or plan) - if (tasks.length === 0 && (!planPath || !existsSync(planPath))) { - issues.push({ - severity: "warning", - code: "empty_slice", - scope: "slice", - unitId, - message: `${unitId} has no plan and no tasks — may be abandoned`, - }); - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function runDoctorLite( - projectDir: string, - scope?: string, -): DoctorResult { - const sfRoot = resolveSFRoot(projectDir); - const issues: DoctorIssue[] = []; - - if (!existsSync(sfRoot)) { - return { - ok: true, - issues: [ - { - severity: "info", - code: "no_sf_directory", - scope: "project", - unitId: "", - message: "No .sf/ directory found — project not initialized", - }, - ], - counts: { error: 0, warning: 0, info: 1 }, - }; - } - - // Project-level checks - checkProjectLevel(sfRoot, issues); - - // Milestone + slice checks - const milestoneIds = scope - ? findMilestoneIds(sfRoot).filter((id) => id === scope) - : findMilestoneIds(sfRoot); - - for (const mid of milestoneIds) { - checkMilestoneLevel(sfRoot, mid, issues); - - const sliceIds = findSliceIds(sfRoot, mid); - for (const sid of sliceIds) { - checkSliceLevel(sfRoot, mid, sid, issues); - } - } - - const counts = { - error: issues.filter((i) => i.severity === "error").length, - warning: issues.filter((i) => i.severity === "warning").length, - info: issues.filter((i) => i.severity === "info").length, - }; - - return { ok: counts.error === 0, issues, counts }; -} diff --git a/packages/mcp-server/src/readers/graph.test.ts b/packages/mcp-server/src/readers/graph.test.ts deleted file mode 100644 index e288b7424..000000000 --- a/packages/mcp-server/src/readers/graph.test.ts +++ /dev/null @@ -1,752 +0,0 @@ -// SF project graph reader tests -// Copyright (c) 2026 Jeremy McSpadden - -import assert from "node:assert/strict"; -import { randomBytes } from "node:crypto"; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - it, -} from "vitest"; -import type { KnowledgeGraph } from "./graph.js"; -import { - buildGraph, - graphDiff, - graphQuery, - graphStatus, - writeGraph, - writeSnapshot, -} from "./graph.js"; - -// --------------------------------------------------------------------------- -// Fixture helpers -// --------------------------------------------------------------------------- - -function tmpProject(): string { - const dir = join(tmpdir(), `sf-graph-test-${randomBytes(4).toString("hex")}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function writeFixture(base: string, relPath: string, content: string): void { - const full = join(base, relPath); - mkdirSync(join(full, ".."), { recursive: true }); - writeFileSync(full, content, "utf-8"); -} - -function makeProjectWithArtifacts(projectDir: string): void { - writeFixture( - projectDir, - ".sf/STATE.md", - [ - "# SF State", - "", - "**Active Milestone:** M001: Auth System", - "**Active Slice:** S01: Login flow", - "**Phase:** execution", - "", - "## Milestone Registry", - "", - "- 🔄 **M001:** Auth System", - "", - "## Next Action", - "", - "Execute T01 in S01.", - ].join("\n"), - ); - - writeFixture( - projectDir, - ".sf/KNOWLEDGE.md", - [ - "# Project Knowledge", - "", - "## Rules", - "", - "| # | Scope | Rule | Why | Added |", - "|---|-------|------|-----|-------|", - "| K001 | auth | Hash passwords with bcrypt | Security requirement | manual |", - "| K002 | db | Use transactions for multi-table | Data consistency | auto |", - "", - "## Patterns", - "", - "| # | Pattern | Where | Notes |", - "|---|---------|-------|-------|", - "| P001 | Singleton services | services/ | Prevents duplication |", - "", - "## Lessons Learned", - "", - "| # | What Happened | Root Cause | Fix | Scope |", - "|---|--------------|------------|-----|-------|", - "| L001 | CI tests failed | Env diff | Added setup script | testing |", - ].join("\n"), - ); - - writeFixture( - projectDir, - ".sf/milestones/M001/M001-ROADMAP.md", - [ - "# M001: Auth System", - "", - "## Vision", - "", - "Build authentication for the platform.", - "", - "## Slice Overview", - "", - "| ID | Slice | Risk | Depends | Done | After this |", - "|----|-------|------|---------|------|------------|", - "| S01 | Login flow | low | — | 🔄 | Users can log in |", - ].join("\n"), - ); - - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/S01-PLAN.md", - [ - "# S01: Login flow", - "", - "## Tasks", - "", - "- [ ] **T01: Implement login endpoint** — Core auth logic", - "- [ ] **T02: Add session management** — Keep users logged in", - ].join("\n"), - ); -} - -// --------------------------------------------------------------------------- -// LEARNINGS.md fixture helpers -// --------------------------------------------------------------------------- - -function writeLearningsFixture( - projectDir: string, - milestoneId: string, - content: string, -): void { - writeFixture( - projectDir, - `.sf/milestones/${milestoneId}/${milestoneId}-LEARNINGS.md`, - content, - ); -} - -const SAMPLE_LEARNINGS = `--- -phase: "M001" -phase_name: "User Auth" -project: "my-project" -generated: "2026-04-15T10:00:00Z" -counts: - decisions: 2 - lessons: 1 - patterns: 1 - surprises: 1 -missing_artifacts: [] ---- - -# Learnings: User Auth - -## Decisions -- Use JWT for stateless auth across services. - Source: M001-PLAN.md/Architecture - -- Store refresh tokens in HTTP-only cookies only. - Source: M001-PLAN.md/Security - -## Lessons -- Integration tests need a real DB — mocks missed migration bugs. - Source: M001-SUMMARY.md/Testing - -## Patterns -- Repository pattern abstracts DB access and simplifies testing. - Source: M001-PLAN.md/Design - -## Surprises -- Token expiry edge case caused silent auth failures in prod. - Source: M001-SUMMARY.md/Issues -`; - -// --------------------------------------------------------------------------- -// buildGraph tests -// --------------------------------------------------------------------------- - -describe("buildGraph", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns nodeCount > 0 for a project with artifacts", async () => { - const graph = await buildGraph(projectDir); - assert.ok( - graph.nodes.length > 0, - `Expected nodes, got ${graph.nodes.length}`, - ); - }); - - it("returns edgeCount >= 0 (valid graph structure)", async () => { - const graph = await buildGraph(projectDir); - assert.ok(graph.edges.length >= 0); - }); - - it("includes builtAt ISO timestamp", async () => { - const graph = await buildGraph(projectDir); - assert.ok(typeof graph.builtAt === "string"); - assert.ok(!Number.isNaN(Date.parse(graph.builtAt))); - }); - - it("skips unparseable artifact and does not throw", async () => { - const badProject = tmpProject(); - // Write a corrupt/minimal STATE.md that is technically valid but empty - writeFixture( - badProject, - ".sf/STATE.md", - "not valid sf state at all \0\0\0", - ); - // Should not throw - const graph = await buildGraph(badProject); - assert.ok(graph.nodes.length >= 0); - rmSync(badProject, { recursive: true, force: true }); - }); - - it("returns empty graph for project with no .sf/ directory", async () => { - const emptyProject = tmpProject(); - const graph = await buildGraph(emptyProject); - assert.ok(graph.nodes.length >= 0); // no throw - assert.equal(typeof graph.builtAt, "string"); - rmSync(emptyProject, { recursive: true, force: true }); - }); - - it("nodes have required fields: id, label, type, confidence", async () => { - const graph = await buildGraph(projectDir); - for (const node of graph.nodes) { - assert.ok(typeof node.id === "string", "node.id must be string"); - assert.ok(typeof node.label === "string", "node.label must be string"); - assert.ok(typeof node.type === "string", "node.type must be string"); - assert.ok( - node.confidence === "EXTRACTED" || - node.confidence === "INFERRED" || - node.confidence === "AMBIGUOUS", - `Invalid confidence: ${node.confidence}`, - ); - } - }); -}); - -// --------------------------------------------------------------------------- -// buildGraph — LEARNINGS.md parsing tests -// --------------------------------------------------------------------------- - -describe("buildGraph — LEARNINGS.md parsing", () => { - let projectDir: string; - - beforeEach(() => { - projectDir = tmpProject(); - // Create minimal milestone directory so parseMilestoneFiles finds it - mkdirSync(join(projectDir, ".sf", "milestones", "M001"), { - recursive: true, - }); - writeLearningsFixture(projectDir, "M001", SAMPLE_LEARNINGS); - }); - - afterEach(() => rmSync(projectDir, { recursive: true, force: true })); - - it("extracts decision nodes from ## Decisions section", async () => { - const graph = await buildGraph(projectDir); - // Decisions should be extracted with a 'decision' type (or similar existing type) - const decisionNodes = graph.nodes.filter((n) => - n.id.includes("decision:M001"), - ); - assert.ok( - decisionNodes.length >= 2, - `Expected >= 2 decision nodes, got ${decisionNodes.length}`, - ); - }); - - it("extracts lesson nodes from ## Lessons section", async () => { - const graph = await buildGraph(projectDir); - const lessonNodes = graph.nodes.filter((n) => n.id.includes("lesson:M001")); - assert.ok( - lessonNodes.length >= 1, - `Expected >= 1 lesson node, got ${lessonNodes.length}`, - ); - assert.ok( - lessonNodes.every((n) => n.type === "lesson"), - 'All lesson nodes must have type "lesson"', - ); - }); - - it("extracts pattern nodes from ## Patterns section", async () => { - const graph = await buildGraph(projectDir); - const patternNodes = graph.nodes.filter((n) => - n.id.includes("pattern:M001"), - ); - assert.ok( - patternNodes.length >= 1, - `Expected >= 1 pattern node, got ${patternNodes.length}`, - ); - assert.ok( - patternNodes.every((n) => n.type === "pattern"), - 'All pattern nodes must have type "pattern"', - ); - }); - - it("maps surprises to lesson nodes", async () => { - const graph = await buildGraph(projectDir); - // Surprises should be mapped to lesson type since no "surprise" NodeType exists - const surpriseNodes = graph.nodes.filter((n) => - n.id.includes("surprise:M001"), - ); - assert.ok( - surpriseNodes.length >= 1, - `Expected >= 1 surprise node, got ${surpriseNodes.length}`, - ); - assert.ok( - surpriseNodes.every((n) => n.type === "lesson"), - 'Surprises must be mapped to type "lesson"', - ); - }); - - it("node labels contain the learning text", async () => { - const graph = await buildGraph(projectDir); - const hasJwtDecision = graph.nodes.some( - (n) => - n.label.toLowerCase().includes("jwt") || - n.description?.toLowerCase().includes("jwt"), - ); - assert.ok(hasJwtDecision, "Expected a node describing the JWT decision"); - }); - - it("node description includes source attribution", async () => { - const graph = await buildGraph(projectDir); - const learningNodes = graph.nodes.filter( - (n) => - n.id.includes(":M001:") || - n.id.match(/:(decision|lesson|pattern|surprise):M001/), - ); - const withSource = learningNodes.filter( - (n) => - n.description?.includes("Source:") || - n.description?.includes("M001-PLAN"), - ); - assert.ok( - withSource.length > 0, - "Expected at least one node with source attribution in description", - ); - }); - - it("adds relates_to edge from learning node to milestone node", async () => { - const graph = await buildGraph(projectDir); - const edgesToMilestone = graph.edges.filter( - (e) => e.to === "milestone:M001" || e.from === "milestone:M001", - ); - // At least one learning node should relate to the milestone - const learningEdges = graph.edges.filter( - (e) => - (e.from.includes("M001") && - (e.type === "relates_to" || e.type === "contains")) || - (e.to.includes("M001") && e.type === "relates_to"), - ); - assert.ok( - learningEdges.length > 0 || edgesToMilestone.length > 0, - "Expected edges connecting learning nodes to milestone", - ); - }); - - it("skips LEARNINGS.md gracefully when file is malformed", async () => { - const badProject = tmpProject(); - mkdirSync(join(badProject, ".sf", "milestones", "M002"), { - recursive: true, - }); - writeLearningsFixture( - badProject, - "M002", - "\0\0\0 not valid yaml or markdown \0\0\0", - ); - // Must not throw - const graph = await buildGraph(badProject); - assert.ok(graph.nodes.length >= 0); - assert.equal(typeof graph.builtAt, "string"); - rmSync(badProject, { recursive: true, force: true }); - }); - - it("produces no learning nodes when all sections are empty", async () => { - const emptyProject = tmpProject(); - mkdirSync(join(emptyProject, ".sf", "milestones", "M003"), { - recursive: true, - }); - writeLearningsFixture( - emptyProject, - "M003", - `--- -phase: "M003" -phase_name: "Empty" -project: "test" -generated: "2026-04-15T10:00:00Z" -counts: - decisions: 0 - lessons: 0 - patterns: 0 - surprises: 0 -missing_artifacts: [] ---- - -# Learnings: Empty - -## Decisions - -## Lessons - -## Patterns - -## Surprises -`, - ); - const graph = await buildGraph(emptyProject); - const learningNodes = graph.nodes.filter( - (n) => - n.id.includes("decision:M003") || - n.id.includes("lesson:M003") || - n.id.includes("pattern:M003") || - n.id.includes("surprise:M003"), - ); - assert.equal( - learningNodes.length, - 0, - "Empty sections should produce no nodes", - ); - rmSync(emptyProject, { recursive: true, force: true }); - }); - - it("does not crash when LEARNINGS.md is missing entirely", async () => { - const noLearningsProject = tmpProject(); - mkdirSync(join(noLearningsProject, ".sf", "milestones", "M004"), { - recursive: true, - }); - // No LEARNINGS.md file written - const graph = await buildGraph(noLearningsProject); - assert.ok(graph.nodes.length >= 0); - rmSync(noLearningsProject, { recursive: true, force: true }); - }); -}); - -// --------------------------------------------------------------------------- -// writeGraph tests -// --------------------------------------------------------------------------- - -describe("writeGraph", () => { - let projectDir: string; - let graph: KnowledgeGraph; - - beforeAll(async () => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - graph = await buildGraph(projectDir); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("creates graph.json in .sf/graphs/ after writeGraph()", async () => { - const sfRoot = join(projectDir, ".sf"); - await writeGraph(sfRoot, graph); - const graphPath = join(sfRoot, "graphs", "graph.json"); - assert.ok(existsSync(graphPath), `Expected ${graphPath} to exist`); - }); - - it("write is atomic — no temp file remains after writeGraph()", async () => { - const sfRoot = join(projectDir, ".sf"); - await writeGraph(sfRoot, graph); - const tmpPath = join(sfRoot, "graphs", "graph.tmp.json"); - assert.ok( - !existsSync(tmpPath), - "Temp file should not exist after successful write", - ); - }); - - it("written graph.json is valid JSON with nodes and edges", async () => { - const sfRoot = join(projectDir, ".sf"); - await writeGraph(sfRoot, graph); - const raw = readFileSync(join(sfRoot, "graphs", "graph.json"), "utf-8"); - const parsed = JSON.parse(raw) as KnowledgeGraph; - assert.ok(Array.isArray(parsed.nodes)); - assert.ok(Array.isArray(parsed.edges)); - assert.ok(typeof parsed.builtAt === "string"); - }); -}); - -// --------------------------------------------------------------------------- -// graphStatus tests -// --------------------------------------------------------------------------- - -describe("graphStatus", () => { - let projectDir: string; - - beforeEach(() => { - projectDir = tmpProject(); - }); - - afterEach(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns { exists: false } when no graph.json exists", async () => { - const status = await graphStatus(projectDir); - assert.equal(status.exists, false); - }); - - it("returns { exists: true, nodeCount, edgeCount, ageHours } when graph exists", async () => { - makeProjectWithArtifacts(projectDir); - const sfRoot = join(projectDir, ".sf"); - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - - const status = await graphStatus(projectDir); - assert.equal(status.exists, true); - assert.ok(typeof status.nodeCount === "number"); - assert.ok(typeof status.edgeCount === "number"); - assert.ok(typeof status.ageHours === "number"); - assert.ok(status.ageHours >= 0); - }); - - it("stale = false for a freshly built graph", async () => { - makeProjectWithArtifacts(projectDir); - const sfRoot = join(projectDir, ".sf"); - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - - const status = await graphStatus(projectDir); - assert.equal(status.stale, false); - }); - - it("stale = true for a graph older than 24h (builtAt backdated)", async () => { - makeProjectWithArtifacts(projectDir); - const sfRoot = join(projectDir, ".sf"); - mkdirSync(join(sfRoot, "graphs"), { recursive: true }); - - // Write a graph with a builtAt 25 hours ago - const oldGraph: KnowledgeGraph = { - nodes: [], - edges: [], - builtAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), - }; - writeFileSync( - join(sfRoot, "graphs", "graph.json"), - JSON.stringify(oldGraph), - "utf-8", - ); - - const status = await graphStatus(projectDir); - assert.equal(status.exists, true); - assert.equal(status.stale, true); - }); -}); - -// --------------------------------------------------------------------------- -// graphQuery tests -// --------------------------------------------------------------------------- - -describe("graphQuery", () => { - let projectDir: string; - - beforeAll(async () => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - const sfRoot = join(projectDir, ".sf"); - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns matching nodes for a known term", async () => { - const result = await graphQuery(projectDir, "auth"); - assert.ok(Array.isArray(result.nodes)); - // Should match nodes with 'auth' in label or description - assert.ok( - result.nodes.length > 0, - 'Expected at least one match for "auth"', - ); - }); - - it("returns empty array for a term that matches nothing", async () => { - const result = await graphQuery(projectDir, "xxxxxxnotfound999zzz"); - assert.ok(Array.isArray(result.nodes)); - assert.equal(result.nodes.length, 0); - }); - - it("search is case-insensitive", async () => { - const lower = await graphQuery(projectDir, "auth"); - const upper = await graphQuery(projectDir, "AUTH"); - assert.deepEqual( - lower.nodes.map((n) => n.id).sort(), - upper.nodes.map((n) => n.id).sort(), - ); - }); - - it("budget trims AMBIGUOUS edges first", async () => { - const sfRoot = join(projectDir, ".sf"); - // Write a graph with mixed confidence edges - const mixedGraph: KnowledgeGraph = { - builtAt: new Date().toISOString(), - nodes: [ - { - id: "n1", - label: "seed node budget", - type: "milestone", - confidence: "EXTRACTED", - }, - { - id: "n2", - label: "connected via AMBIGUOUS", - type: "task", - confidence: "AMBIGUOUS", - }, - { - id: "n3", - label: "connected via INFERRED", - type: "task", - confidence: "INFERRED", - }, - ], - edges: [ - { from: "n1", to: "n2", type: "contains", confidence: "AMBIGUOUS" }, - { from: "n1", to: "n3", type: "contains", confidence: "INFERRED" }, - ], - }; - await writeGraph(sfRoot, mixedGraph); - - // With a very small budget, AMBIGUOUS edges should be trimmed first - const result = await graphQuery(projectDir, "seed node budget", 10); - // At minimum, the seed node itself should be present - assert.ok( - result.nodes.some((n) => n.id === "n1"), - "Seed node should be in result", - ); - - // Restore the original graph - const originalGraph = await buildGraph(projectDir); - await writeGraph(sfRoot, originalGraph); - }); -}); - -// --------------------------------------------------------------------------- -// writeSnapshot + graphDiff tests -// --------------------------------------------------------------------------- - -describe("graphDiff", () => { - let projectDir: string; - - beforeEach(async () => { - projectDir = tmpProject(); - makeProjectWithArtifacts(projectDir); - const sfRoot = join(projectDir, ".sf"); - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - }); - - afterEach(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns empty diff when comparing graph to itself (snapshot = current)", async () => { - const sfRoot = join(projectDir, ".sf"); - await writeSnapshot(sfRoot); - const diff = await graphDiff(projectDir); - assert.ok(Array.isArray(diff.nodes.added)); - assert.ok(Array.isArray(diff.nodes.removed)); - assert.ok(Array.isArray(diff.nodes.changed)); - assert.equal(diff.nodes.added.length, 0); - assert.equal(diff.nodes.removed.length, 0); - }); - - it("returns added nodes when a new node appears after snapshot", async () => { - const sfRoot = join(projectDir, ".sf"); - // Take snapshot of the original graph - await writeSnapshot(sfRoot); - - // Now write a graph with an extra node - const extraGraph: KnowledgeGraph = { - builtAt: new Date().toISOString(), - nodes: [ - { - id: "brand-new-node", - label: "New Feature", - type: "milestone", - confidence: "EXTRACTED", - }, - ], - edges: [], - }; - await writeGraph(sfRoot, extraGraph); - - const diff = await graphDiff(projectDir); - assert.ok( - diff.nodes.added.includes("brand-new-node"), - "new node should be in added", - ); - }); - - it("returns removed nodes when a node disappears after snapshot", async () => { - const sfRoot = join(projectDir, ".sf"); - // Create snapshot with a node that won't exist in current graph - const snapshotGraph: KnowledgeGraph = { - builtAt: new Date().toISOString(), - nodes: [ - { - id: "old-node-to-be-removed", - label: "Old", - type: "task", - confidence: "EXTRACTED", - }, - ], - edges: [], - }; - writeFileSync( - join(sfRoot, "graphs", ".last-build-snapshot.json"), - JSON.stringify({ - ...snapshotGraph, - snapshotAt: new Date().toISOString(), - }), - "utf-8", - ); - - // Current graph.json has no such node - const diff = await graphDiff(projectDir); - assert.ok( - diff.nodes.removed.includes("old-node-to-be-removed"), - "old node should be in removed", - ); - }); - - it("returns empty diff structure when no snapshot exists", async () => { - // No snapshot file — diff should be empty/meaningful - const diff = await graphDiff(projectDir); - assert.ok(Array.isArray(diff.nodes.added)); - assert.ok(Array.isArray(diff.nodes.removed)); - assert.ok(Array.isArray(diff.nodes.changed)); - assert.ok(Array.isArray(diff.edges.added)); - assert.ok(Array.isArray(diff.edges.removed)); - }); - - it("writeSnapshot creates .last-build-snapshot.json with snapshotAt", async () => { - const sfRoot = join(projectDir, ".sf"); - await writeSnapshot(sfRoot); - const snapshotPath = join(sfRoot, "graphs", ".last-build-snapshot.json"); - assert.ok(existsSync(snapshotPath)); - const raw = readFileSync(snapshotPath, "utf-8"); - const parsed = JSON.parse(raw) as KnowledgeGraph & { snapshotAt: string }; - assert.ok(typeof parsed.snapshotAt === "string"); - assert.ok(!Number.isNaN(Date.parse(parsed.snapshotAt))); - }); -}); diff --git a/packages/mcp-server/src/readers/graph.ts b/packages/mcp-server/src/readers/graph.ts deleted file mode 100644 index 524c20a7b..000000000 --- a/packages/mcp-server/src/readers/graph.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * MCP graph reader compatibility exports. - * - * Purpose: keep MCP as a transport wrapper over the SF project graph while the - * core graph implementation lives in `@singularity-forge/pi-agent-core`. - * - * Consumer: MCP `sf_graph` tool and older imports from `readers/graph.js`. - */ - -export type { - ConfidenceTier, - EdgeType, - GraphDiffResult, - GraphEdge, - GraphNode, - GraphQueryResult, - GraphStatusResult, - KnowledgeGraph, - NodeType, -} from "@singularity-forge/pi-agent-core"; -export { - buildGraph, - graphDiff, - graphQuery, - graphStatus, - resolveSFRoot, - writeGraph, - writeSnapshot, -} from "@singularity-forge/pi-agent-core"; diff --git a/packages/mcp-server/src/readers/index.ts b/packages/mcp-server/src/readers/index.ts deleted file mode 100644 index a122c7136..000000000 --- a/packages/mcp-server/src/readers/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -// SF MCP Server — readers barrel export -// Copyright (c) 2026 Jeremy McSpadden - -export { resolveSFRoot } from "@singularity-forge/pi-agent-core"; -export type { CaptureEntry, CapturesResult } from "./captures.js"; -export { readCaptures } from "./captures.js"; -export type { DoctorIssue, DoctorResult } from "./doctor-lite.js"; -export { runDoctorLite } from "./doctor-lite.js"; -export type { - ConfidenceTier, - EdgeType, - GraphDiffResult, - GraphEdge, - GraphNode, - GraphQueryResult, - GraphStatusResult, - KnowledgeGraph, - NodeType, -} from "./graph.js"; -export { - buildGraph, - graphDiff, - graphQuery, - graphStatus, - writeGraph, - writeSnapshot, -} from "./graph.js"; -export type { KnowledgeEntry, KnowledgeResult } from "./knowledge.js"; -export { readKnowledge } from "./knowledge.js"; -export type { HistoryResult, MetricsUnit } from "./metrics.js"; -export { readHistory } from "./metrics.js"; -export { resolveRootFile } from "./paths.js"; -export type { - MilestoneInfo, - RoadmapResult, - SliceInfo, - TaskInfo, -} from "./roadmap.js"; -export { readRoadmap } from "./roadmap.js"; -export type { ProgressResult } from "./state.js"; -export { readProgress } from "./state.js"; diff --git a/packages/mcp-server/src/readers/knowledge.ts b/packages/mcp-server/src/readers/knowledge.ts deleted file mode 100644 index 94709beb2..000000000 --- a/packages/mcp-server/src/readers/knowledge.ts +++ /dev/null @@ -1,129 +0,0 @@ -// SF MCP Server — knowledge base reader -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync, readFileSync } from "node:fs"; -import { resolveRootFile, resolveSFRoot } from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type KnowledgeType = "rule" | "pattern" | "lesson"; - -export interface KnowledgeEntry { - id: string; - type: KnowledgeType; - scope: string; - content: string; - addedAt: string; -} - -export interface KnowledgeResult { - entries: KnowledgeEntry[]; - counts: { rules: number; patterns: number; lessons: number }; -} - -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - -function parseTableRows( - section: string, - type: KnowledgeType, -): KnowledgeEntry[] { - const entries: KnowledgeEntry[] = []; - const lines = section.split("\n"); - - for (const line of lines) { - if (!line.includes("|")) continue; - const cells = line - .split("|") - .map((c) => c.trim()) - .filter(Boolean); - if (cells.length < 3) continue; - // Skip header/separator - if (cells[0].startsWith("#") || cells[0].startsWith("-")) continue; - - const id = cells[0]; - if (!/^[KPL]\d+$/i.test(id)) continue; - - if (type === "rule" && cells.length >= 5) { - entries.push({ - id, - type, - scope: cells[1], - content: cells[2], - addedAt: cells[4] ?? "", - }); - } else if (type === "pattern" && cells.length >= 4) { - entries.push({ - id, - type, - scope: cells[2] ?? "", - content: cells[1], - addedAt: cells[3] ?? "", - }); - } else if (type === "lesson" && cells.length >= 5) { - entries.push({ - id, - type, - scope: cells[4] ?? "", - content: `${cells[1]} — Root cause: ${cells[2]} — Fix: ${cells[3]}`, - addedAt: "", - }); - } - } - - return entries; -} - -function parseKnowledgeMarkdown(content: string): KnowledgeEntry[] { - const entries: KnowledgeEntry[] = []; - - // Find ## Rules section - const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i); - if (rulesMatch) { - entries.push(...parseTableRows(rulesMatch[1], "rule")); - } - - // Find ## Patterns section - const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i); - if (patternsMatch) { - entries.push(...parseTableRows(patternsMatch[1], "pattern")); - } - - // Find ## Lessons Learned section - const lessonsMatch = content.match( - /## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i, - ); - if (lessonsMatch) { - entries.push(...parseTableRows(lessonsMatch[1], "lesson")); - } - - return entries; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function readKnowledge(projectDir: string): KnowledgeResult { - const sf = resolveSFRoot(projectDir); - const knowledgePath = resolveRootFile(sf, "KNOWLEDGE.md"); - - if (!existsSync(knowledgePath)) { - return { entries: [], counts: { rules: 0, patterns: 0, lessons: 0 } }; - } - - const content = readFileSync(knowledgePath, "utf-8"); - const entries = parseKnowledgeMarkdown(content); - - return { - entries, - counts: { - rules: entries.filter((e) => e.type === "rule").length, - patterns: entries.filter((e) => e.type === "pattern").length, - lessons: entries.filter((e) => e.type === "lesson").length, - }, - }; -} diff --git a/packages/mcp-server/src/readers/metrics.ts b/packages/mcp-server/src/readers/metrics.ts deleted file mode 100644 index 631b130e0..000000000 --- a/packages/mcp-server/src/readers/metrics.ts +++ /dev/null @@ -1,122 +0,0 @@ -// SF MCP Server — metrics/history reader -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync, readFileSync } from "node:fs"; -import { resolveRootFile, resolveSFRoot } from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface MetricsUnit { - type: string; - id: string; - model: string; - startedAt: number; - finishedAt: number; - tokens: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - total: number; - }; - cost: number; - toolCalls: number; - apiRequests: number; -} - -export interface HistoryResult { - entries: MetricsUnit[]; - totals: { - cost: number; - tokens: { input: number; output: number; total: number }; - units: number; - durationMs: number; - }; -} - -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - -function parseMetricsJson(content: string): MetricsUnit[] { - try { - const data = JSON.parse(content); - if (!data.units || !Array.isArray(data.units)) return []; - - return data.units.map((u: Record) => ({ - type: String(u.type ?? "unknown"), - id: String(u.id ?? ""), - model: String(u.model ?? "unknown"), - startedAt: Number(u.startedAt ?? 0), - finishedAt: Number(u.finishedAt ?? 0), - tokens: { - input: Number((u.tokens as Record)?.input ?? 0), - output: Number((u.tokens as Record)?.output ?? 0), - cacheRead: Number( - (u.tokens as Record)?.cacheRead ?? 0, - ), - cacheWrite: Number( - (u.tokens as Record)?.cacheWrite ?? 0, - ), - total: Number((u.tokens as Record)?.total ?? 0), - }, - cost: Number(u.cost ?? 0), - toolCalls: Number(u.toolCalls ?? 0), - apiRequests: Number(u.apiRequests ?? 0), - })); - } catch { - return []; - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function readHistory(projectDir: string, limit?: number): HistoryResult { - const sf = resolveSFRoot(projectDir); - - // metrics.json (primary) - const metricsPath = resolveRootFile(sf, "metrics.json"); - let units: MetricsUnit[] = []; - - if (existsSync(metricsPath)) { - const content = readFileSync(metricsPath, "utf-8"); - units = parseMetricsJson(content); - } - - // Sort by startedAt descending (most recent first) - units.sort((a, b) => b.startedAt - a.startedAt); - - // Apply limit - if (limit && limit > 0) { - units = units.slice(0, limit); - } - - // Compute totals from ALL units (not just limited set) - const allUnits = existsSync(metricsPath) - ? parseMetricsJson(readFileSync(metricsPath, "utf-8")) - : []; - - const totals = { - cost: 0, - tokens: { input: 0, output: 0, total: 0 }, - units: allUnits.length, - durationMs: 0, - }; - - for (const u of allUnits) { - totals.cost += u.cost; - totals.tokens.input += u.tokens.input; - totals.tokens.output += u.tokens.output; - totals.tokens.total += u.tokens.total; - totals.durationMs += u.finishedAt - u.startedAt; - } - - // Round cost to 4 decimal places - totals.cost = Math.round(totals.cost * 10000) / 10000; - - return { entries: units, totals }; -} diff --git a/packages/mcp-server/src/readers/paths.ts b/packages/mcp-server/src/readers/paths.ts deleted file mode 100644 index e6a4bca00..000000000 --- a/packages/mcp-server/src/readers/paths.ts +++ /dev/null @@ -1,233 +0,0 @@ -// SF MCP Server — .sf/ directory resolution -// Copyright (c) 2026 Jeremy McSpadden - -import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync, statSync } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; - -/** - * Resolve the .sf/ root directory for a project. - * - * Probes in order: - * 1. projectDir/.sf (fast path) - * 2. git repo root/.sf - * 3. Walk up from projectDir - * 4. Fallback: projectDir/.sf (even if missing — for init) - */ -export function resolveSFRoot(projectDir: string): string { - const resolved = resolve(projectDir); - - // Fast path: .sf/ in the given directory - const direct = join(resolved, ".sf"); - if (existsSync(direct) && statSync(direct).isDirectory()) { - return direct; - } - - // Try git repo root - try { - const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { - cwd: resolved, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - const gitSf = join(gitRoot, ".sf"); - if (existsSync(gitSf) && statSync(gitSf).isDirectory()) { - return gitSf; - } - } catch { - // Not a git repo or git not available - } - - // Walk up from projectDir - let dir = resolved; - while (dir !== dirname(dir)) { - const candidate = join(dir, ".sf"); - if (existsSync(candidate) && statSync(candidate).isDirectory()) { - return candidate; - } - dir = dirname(dir); - } - - // Fallback - return direct; -} - -/** Resolve path to a .sf/ root file (STATE.md, KNOWLEDGE.md, etc.) */ -export function resolveRootFile(sfRoot: string, name: string): string { - return join(sfRoot, name); -} - -/** Resolve path to milestones directory */ -export function milestonesDir(sfRoot: string): string { - return join(sfRoot, "milestones"); -} - -/** - * Find all milestone directory IDs (M001, M002, etc.). - * Handles both bare (M001/) and descriptor (M001-FLIGHT-SIM/) naming. - */ -export function findMilestoneIds(sfRoot: string): string[] { - const dir = milestonesDir(sfRoot); - if (!existsSync(dir)) return []; - - const entries = readdirSync(dir, { withFileTypes: true }); - const ids: string[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const match = entry.name.match(/^(M\d+)/); - if (match) ids.push(match[1]); - } - - return ids.sort(); -} - -/** - * Resolve the actual directory name for a milestone ID. - * M001 might live in M001/ or M001-SOME-DESCRIPTOR/. - */ -export function resolveMilestoneDir( - sfRoot: string, - milestoneId: string, -): string | null { - const dir = milestonesDir(sfRoot); - if (!existsSync(dir)) return null; - - // Fast path: exact match - const exact = join(dir, milestoneId); - if (existsSync(exact) && statSync(exact).isDirectory()) return exact; - - // Prefix match - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith(milestoneId)) { - return join(dir, entry.name); - } - } - - return null; -} - -/** - * Resolve a milestone-level file (M001-ROADMAP.md, M001-CONTEXT.md, etc.). - * Handles various naming conventions. - */ -export function resolveMilestoneFile( - sfRoot: string, - milestoneId: string, - suffix: string, -): string | null { - const mDir = resolveMilestoneDir(sfRoot, milestoneId); - if (!mDir) return null; - - const dirName = basename(mDir); - - // Try: M001-ROADMAP.md, then DIRNAME-ROADMAP.md - const candidates = [ - join(mDir, `${milestoneId}-${suffix}.md`), - join(mDir, `${dirName}-${suffix}.md`), - join(mDir, `${suffix}.md`), - ]; - - for (const c of candidates) { - if (existsSync(c)) return c; - } - return null; -} - -/** Find all slice IDs within a milestone (S01, S02, etc.) */ -export function findSliceIds(sfRoot: string, milestoneId: string): string[] { - const mDir = resolveMilestoneDir(sfRoot, milestoneId); - if (!mDir) return []; - - const slicesDir = join(mDir, "slices"); - if (!existsSync(slicesDir)) return []; - - const entries = readdirSync(slicesDir, { withFileTypes: true }); - const ids: string[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const match = entry.name.match(/^(S\d+)/); - if (match) ids.push(match[1]); - } - - return ids.sort(); -} - -/** Resolve the actual directory for a slice */ -export function resolveSliceDir( - sfRoot: string, - milestoneId: string, - sliceId: string, -): string | null { - const mDir = resolveMilestoneDir(sfRoot, milestoneId); - if (!mDir) return null; - - const slicesDir = join(mDir, "slices"); - if (!existsSync(slicesDir)) return null; - - const exact = join(slicesDir, sliceId); - if (existsSync(exact) && statSync(exact).isDirectory()) return exact; - - const entries = readdirSync(slicesDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith(sliceId)) { - return join(slicesDir, entry.name); - } - } - return null; -} - -/** Resolve a slice-level file (S01-PLAN.md, etc.) */ -export function resolveSliceFile( - sfRoot: string, - milestoneId: string, - sliceId: string, - suffix: string, -): string | null { - const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId); - if (!sDir) return null; - - const dirName = basename(sDir); - const candidates = [ - join(sDir, `${sliceId}-${suffix}.md`), - join(sDir, `${dirName}-${suffix}.md`), - join(sDir, `${suffix}.md`), - ]; - - for (const c of candidates) { - if (existsSync(c)) return c; - } - return null; -} - -/** Find all task files in a slice's tasks/ directory */ -export function findTaskFiles( - sfRoot: string, - milestoneId: string, - sliceId: string, -): Array<{ id: string; hasPlan: boolean; hasSummary: boolean }> { - const sDir = resolveSliceDir(sfRoot, milestoneId, sliceId); - if (!sDir) return []; - - const tasksDir = join(sDir, "tasks"); - if (!existsSync(tasksDir)) return []; - - const files = readdirSync(tasksDir); - const taskMap = new Map(); - - for (const f of files) { - const match = f.match(/^(T\d+).*-(PLAN|SUMMARY)\.md$/i); - if (!match) continue; - const [, id, type] = match; - const existing = taskMap.get(id) ?? { hasPlan: false, hasSummary: false }; - if (type.toUpperCase() === "PLAN") existing.hasPlan = true; - if (type.toUpperCase() === "SUMMARY") existing.hasSummary = true; - taskMap.set(id, existing); - } - - return Array.from(taskMap.entries()) - .map(([id, info]) => ({ id, ...info })) - .sort((a, b) => a.id.localeCompare(b.id)); -} diff --git a/packages/mcp-server/src/readers/readers.test.ts b/packages/mcp-server/src/readers/readers.test.ts deleted file mode 100644 index ca3dd9157..000000000 --- a/packages/mcp-server/src/readers/readers.test.ts +++ /dev/null @@ -1,617 +0,0 @@ -// SF MCP Server — reader tests -// Copyright (c) 2026 Jeremy McSpadden - -import assert from "node:assert/strict"; -import { randomBytes } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, it } from "vitest"; -import { readCaptures } from "./captures.js"; -import { runDoctorLite } from "./doctor-lite.js"; -import { readKnowledge } from "./knowledge.js"; -import { readHistory } from "./metrics.js"; -import { readRoadmap } from "./roadmap.js"; -import { readProgress } from "./state.js"; - -// --------------------------------------------------------------------------- -// Test fixture helpers -// --------------------------------------------------------------------------- - -function tmpProject(): string { - const dir = join(tmpdir(), `sf-mcp-test-${randomBytes(4).toString("hex")}`); - mkdirSync(dir, { recursive: true }); - return dir; -} - -function writeFixture(base: string, relPath: string, content: string): void { - const full = join(base, relPath); - mkdirSync(join(full, ".."), { recursive: true }); - writeFileSync(full, content, "utf-8"); -} - -// --------------------------------------------------------------------------- -// readProgress tests -// --------------------------------------------------------------------------- - -describe("readProgress", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - - writeFixture( - projectDir, - ".sf/STATE.md", - `# SF State - -**Active Milestone:** M002: Auth System -**Active Slice:** S01: Login flow -**Phase:** execution -**Requirements Status:** 5 active · 2 validated · 1 deferred · 0 out of scope - -## Milestone Registry - -- ☑ **M001:** Core Setup -- 🔄 **M002:** Auth System -- ⬜ **M003:** Dashboard - -## Blockers - -- Waiting on OAuth provider approval - -## Next Action - -Execute T02 in S01 — implement token refresh. -`, - ); - - // Create filesystem structure - const m1 = ".sf/milestones/M001/slices/S01/tasks"; - writeFixture(projectDir, `${m1}/T01-PLAN.md`, "# T01"); - writeFixture(projectDir, `${m1}/T01-SUMMARY.md`, "# T01 done"); - - const m2 = ".sf/milestones/M002/slices/S01/tasks"; - writeFixture(projectDir, `${m2}/T01-PLAN.md`, "# T01"); - writeFixture(projectDir, `${m2}/T01-SUMMARY.md`, "# T01 done"); - writeFixture(projectDir, `${m2}/T02-PLAN.md`, "# T02"); - - mkdirSync(join(projectDir, ".sf/milestones/M003"), { recursive: true }); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("parses active milestone from STATE.md", () => { - const result = readProgress(projectDir); - assert.deepEqual(result.activeMilestone, { - id: "M002", - title: "Auth System", - }); - }); - - it("parses active slice", () => { - const result = readProgress(projectDir); - assert.deepEqual(result.activeSlice, { id: "S01", title: "Login flow" }); - }); - - it("parses phase", () => { - const result = readProgress(projectDir); - assert.equal(result.phase, "execute"); - }); - - it("parses milestone counts from registry", () => { - const result = readProgress(projectDir); - assert.equal(result.milestones.total, 3); - assert.equal(result.milestones.done, 1); - assert.equal(result.milestones.active, 1); - assert.equal(result.milestones.pending, 1); - }); - - it("counts tasks from filesystem", () => { - const result = readProgress(projectDir); - assert.equal(result.tasks.total, 3); - assert.equal(result.tasks.done, 2); - assert.equal(result.tasks.pending, 1); - }); - - it("parses blockers", () => { - const result = readProgress(projectDir); - assert.equal(result.blockers.length, 1); - assert.ok(result.blockers[0].includes("OAuth")); - }); - - it("parses requirements", () => { - const result = readProgress(projectDir); - assert.equal(result.requirements?.active, 5); - assert.equal(result.requirements?.validated, 2); - assert.equal(result.requirements?.deferred, 1); - }); - - it("parses next action", () => { - const result = readProgress(projectDir); - assert.ok(result.nextAction.includes("T02")); - }); - - it("returns defaults for missing .sf/", () => { - const empty = tmpProject(); - const result = readProgress(empty); - assert.equal(result.phase, "unknown"); - assert.equal(result.milestones.total, 0); - rmSync(empty, { recursive: true, force: true }); - }); -}); - -// --------------------------------------------------------------------------- -// readRoadmap tests -// --------------------------------------------------------------------------- - -describe("readRoadmap", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - - writeFixture( - projectDir, - ".sf/milestones/M001/M001-CONTEXT.md", - "# M001: Core Setup\n", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/M001-ROADMAP.md", - `# M001: Core Setup - -## Vision - -Build the foundation for the project. - -## Slice Overview - -| ID | Slice | Risk | Depends | Done | After this | -|----|-------|------|---------|------|------------| -| S01 | Database schema | low | — | ☑ | DB ready | -| S02 | API endpoints | medium | S01 | 🟫 | REST API live | -`, - ); - - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/S01-PLAN.md", - `# S01: Database schema - -## Tasks - -- [x] **T01: Create migrations** — Set up schema -- [x] **T02: Seed data** — Initial seed -`, - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T01-PLAN.md", - "# T01", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T01-SUMMARY.md", - "# T01 done", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T02-PLAN.md", - "# T02", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T02-SUMMARY.md", - "# T02 done", - ); - - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S02/S02-PLAN.md", - `# S02: API endpoints - -## Tasks - -- [ ] **T01: Auth routes** — Implement auth -- [ ] **T02: User routes** — CRUD users -`, - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S02/tasks/T01-PLAN.md", - "# T01", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S02/tasks/T02-PLAN.md", - "# T02", - ); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns milestone structure", () => { - const result = readRoadmap(projectDir); - assert.equal(result.milestones.length, 1); - assert.equal(result.milestones[0].id, "M001"); - assert.equal(result.milestones[0].title, "Core Setup"); - }); - - it("reads vision from roadmap", () => { - const result = readRoadmap(projectDir); - assert.ok(result.milestones[0].vision.includes("foundation")); - }); - - it("parses slices from roadmap table", () => { - const result = readRoadmap(projectDir); - const slices = result.milestones[0].slices; - assert.equal(slices.length, 2); - assert.equal(slices[0].id, "S01"); - assert.equal(slices[0].title, "Database schema"); - assert.equal(slices[1].id, "S02"); - }); - - it("derives slice status from task summaries", () => { - const result = readRoadmap(projectDir); - const slices = result.milestones[0].slices; - assert.equal(slices[0].status, "done"); - assert.equal(slices[1].status, "pending"); - }); - - it("includes tasks in slices", () => { - const result = readRoadmap(projectDir); - const s01Tasks = result.milestones[0].slices[0].tasks; - assert.equal(s01Tasks.length, 2); - assert.equal(s01Tasks[0].status, "done"); - }); - - it("filters by milestoneId", () => { - const result = readRoadmap(projectDir, "M999"); - assert.equal(result.milestones.length, 0); - }); -}); - -// --------------------------------------------------------------------------- -// readHistory tests -// --------------------------------------------------------------------------- - -describe("readHistory", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - writeFixture( - projectDir, - ".sf/metrics.json", - JSON.stringify({ - version: 1, - projectStartedAt: 1700000000000, - units: [ - { - type: "execute-task", - id: "M001/S01/T01", - model: "claude-sonnet-4", - startedAt: 1700001000000, - finishedAt: 1700002000000, - tokens: { - input: 10000, - output: 3000, - cacheRead: 2000, - cacheWrite: 1000, - total: 16000, - }, - cost: 0.05, - toolCalls: 8, - apiRequests: 3, - }, - { - type: "execute-task", - id: "M001/S01/T02", - model: "claude-sonnet-4", - startedAt: 1700003000000, - finishedAt: 1700004000000, - tokens: { - input: 15000, - output: 5000, - cacheRead: 3000, - cacheWrite: 1500, - total: 24500, - }, - cost: 0.08, - toolCalls: 12, - apiRequests: 5, - }, - ], - }), - ); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("returns all entries sorted by most recent", () => { - const result = readHistory(projectDir); - assert.equal(result.entries.length, 2); - assert.equal(result.entries[0].id, "M001/S01/T02"); // most recent first - }); - - it("computes totals", () => { - const result = readHistory(projectDir); - assert.equal(result.totals.units, 2); - assert.equal(result.totals.cost, 0.13); - assert.equal(result.totals.tokens.total, 40500); - }); - - it("respects limit", () => { - const result = readHistory(projectDir, 1); - assert.equal(result.entries.length, 1); - assert.equal(result.totals.units, 2); // totals still reflect all - }); - - it("returns empty for missing metrics", () => { - const empty = tmpProject(); - mkdirSync(join(empty, ".sf"), { recursive: true }); - const result = readHistory(empty); - assert.equal(result.entries.length, 0); - assert.equal(result.totals.units, 0); - rmSync(empty, { recursive: true, force: true }); - }); -}); - -// --------------------------------------------------------------------------- -// readCaptures tests -// --------------------------------------------------------------------------- - -describe("readCaptures", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - writeFixture( - projectDir, - ".sf/CAPTURES.md", - `# Captures - -### CAP-aaa11111 - -**Text:** Add rate limiting to API -**Captured:** 2026-04-01T10:00:00Z -**Status:** pending - -### CAP-bbb22222 - -**Text:** Refactor auth module -**Captured:** 2026-04-02T10:00:00Z -**Status:** resolved -**Classification:** inject -**Resolution:** Added to M003 roadmap -**Rationale:** Important for security -**Resolved:** 2026-04-03T10:00:00Z -**Milestone:** M003 - -### CAP-ccc33333 - -**Text:** Nice to have: dark mode -**Captured:** 2026-04-02T11:00:00Z -**Status:** resolved -**Classification:** defer -**Resolution:** Deferred to future -**Rationale:** Not blocking -**Resolved:** 2026-04-03T11:00:00Z -`, - ); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("reads all captures", () => { - const result = readCaptures(projectDir, "all"); - assert.equal(result.captures.length, 3); - assert.equal(result.counts.total, 3); - }); - - it("filters pending captures", () => { - const result = readCaptures(projectDir, "pending"); - assert.equal(result.captures.length, 1); - assert.equal(result.captures[0].id, "CAP-aaa11111"); - }); - - it("filters actionable captures (inject, replan, quick-task)", () => { - const result = readCaptures(projectDir, "actionable"); - assert.equal(result.captures.length, 1); - assert.equal(result.captures[0].id, "CAP-bbb22222"); - }); - - it("counts correctly regardless of filter", () => { - const result = readCaptures(projectDir, "pending"); - assert.equal(result.counts.total, 3); - assert.equal(result.counts.pending, 1); - assert.equal(result.counts.actionable, 1); - }); - - it("returns empty for missing CAPTURES.md", () => { - const empty = tmpProject(); - mkdirSync(join(empty, ".sf"), { recursive: true }); - const result = readCaptures(empty); - assert.equal(result.captures.length, 0); - rmSync(empty, { recursive: true, force: true }); - }); -}); - -// --------------------------------------------------------------------------- -// readKnowledge tests -// --------------------------------------------------------------------------- - -describe("readKnowledge", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - writeFixture( - projectDir, - ".sf/KNOWLEDGE.md", - `# Project Knowledge - -## Rules - -| # | Scope | Rule | Why | Added | -|---|-------|------|-----|-------| -| K001 | auth | Hash passwords with bcrypt | Security requirement | manual | -| K002 | db | Use transactions for multi-table | Data consistency | auto | - -## Patterns - -| # | Pattern | Where | Notes | -|---|---------|-------|-------| -| P001 | Singleton services | services/ | Prevents duplication | - -## Lessons Learned - -| # | What Happened | Root Cause | Fix | Scope | -|---|--------------|------------|-----|-------| -| L001 | CI tests failed | Env diff | Added setup script | testing | -`, - ); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("reads all knowledge entries", () => { - const result = readKnowledge(projectDir); - assert.equal(result.entries.length, 4); - }); - - it("counts by type", () => { - const result = readKnowledge(projectDir); - assert.equal(result.counts.rules, 2); - assert.equal(result.counts.patterns, 1); - assert.equal(result.counts.lessons, 1); - }); - - it("parses rule fields correctly", () => { - const result = readKnowledge(projectDir); - const k001 = result.entries.find((e) => e.id === "K001"); - assert.ok(k001); - assert.equal(k001.type, "rule"); - assert.equal(k001.scope, "auth"); - assert.ok(k001.content.includes("bcrypt")); - }); - - it("returns empty for missing KNOWLEDGE.md", () => { - const empty = tmpProject(); - mkdirSync(join(empty, ".sf"), { recursive: true }); - const result = readKnowledge(empty); - assert.equal(result.entries.length, 0); - rmSync(empty, { recursive: true, force: true }); - }); -}); - -// --------------------------------------------------------------------------- -// runDoctorLite tests -// --------------------------------------------------------------------------- - -describe("runDoctorLite", () => { - let projectDir: string; - - beforeAll(() => { - projectDir = tmpProject(); - - // M001: complete milestone (has summary) - writeFixture(projectDir, ".sf/PROJECT.md", "# Test Project"); - writeFixture(projectDir, ".sf/STATE.md", "# SF State"); - writeFixture(projectDir, ".sf/milestones/M001/M001-CONTEXT.md", "# M001"); - writeFixture( - projectDir, - ".sf/milestones/M001/M001-ROADMAP.md", - "# Roadmap", - ); - writeFixture(projectDir, ".sf/milestones/M001/M001-SUMMARY.md", "# Done"); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/S01-PLAN.md", - "# Plan", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T01-PLAN.md", - "# T01", - ); - writeFixture( - projectDir, - ".sf/milestones/M001/slices/S01/tasks/T01-SUMMARY.md", - "# T01 done", - ); - - // M002: incomplete — has all tasks done but no SUMMARY - writeFixture(projectDir, ".sf/milestones/M002/M002-CONTEXT.md", "# M002"); - writeFixture( - projectDir, - ".sf/milestones/M002/M002-ROADMAP.md", - "# Roadmap", - ); - writeFixture( - projectDir, - ".sf/milestones/M002/slices/S01/S01-PLAN.md", - "# Plan", - ); - writeFixture( - projectDir, - ".sf/milestones/M002/slices/S01/tasks/T01-PLAN.md", - "# T01", - ); - writeFixture( - projectDir, - ".sf/milestones/M002/slices/S01/tasks/T01-SUMMARY.md", - "# T01 done", - ); - - // M003: empty — no context, no slices - mkdirSync(join(projectDir, ".sf/milestones/M003"), { recursive: true }); - }); - - afterAll(() => rmSync(projectDir, { recursive: true, force: true })); - - it("detects all-slices-done-missing-summary", () => { - const result = runDoctorLite(projectDir); - const issue = result.issues.find( - (i) => i.code === "all_slices_done_missing_summary", - ); - assert.ok(issue, "Should detect M002 missing summary"); - assert.equal(issue.unitId, "M002"); - }); - - it("detects missing context", () => { - const result = runDoctorLite(projectDir); - const issue = result.issues.find( - (i) => i.code === "missing_context" && i.unitId === "M003", - ); - assert.ok(issue, "Should detect M003 missing context"); - }); - - it("scopes to a single milestone", () => { - const result = runDoctorLite(projectDir, "M001"); - const m002Issues = result.issues.filter((i) => i.unitId.startsWith("M002")); - assert.equal( - m002Issues.length, - 0, - "Should not include M002 when scoped to M001", - ); - }); - - it("returns ok:true for healthy project", () => { - const healthy = tmpProject(); - writeFixture(healthy, ".sf/PROJECT.md", "# Project"); - writeFixture(healthy, ".sf/STATE.md", "# State"); - const result = runDoctorLite(healthy); - assert.equal(result.ok, true); - rmSync(healthy, { recursive: true, force: true }); - }); - - it("handles missing .sf/ gracefully", () => { - const empty = tmpProject(); - const result = runDoctorLite(empty); - assert.equal(result.ok, true); - assert.equal(result.issues[0].code, "no_sf_directory"); - rmSync(empty, { recursive: true, force: true }); - }); -}); diff --git a/packages/mcp-server/src/readers/roadmap.ts b/packages/mcp-server/src/readers/roadmap.ts deleted file mode 100644 index fb4797027..000000000 --- a/packages/mcp-server/src/readers/roadmap.ts +++ /dev/null @@ -1,321 +0,0 @@ -// SF MCP Server — roadmap structure reader -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync, readFileSync } from "node:fs"; -import { - findMilestoneIds, - findSliceIds, - findTaskFiles, - resolveMilestoneFile, - resolveSFRoot, - resolveSliceFile, -} from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface TaskInfo { - id: string; - title: string; - status: "done" | "pending"; -} - -export interface SliceInfo { - id: string; - title: string; - status: "done" | "active" | "pending"; - risk: string; - depends: string[]; - demo: string; - tasks: TaskInfo[]; -} - -export interface MilestoneInfo { - id: string; - title: string; - status: "done" | "active" | "pending" | "parked"; - vision: string; - slices: SliceInfo[]; -} - -export interface RoadmapResult { - milestones: MilestoneInfo[]; -} - -// --------------------------------------------------------------------------- -// ROADMAP.md table parser -// --------------------------------------------------------------------------- - -function parseRoadmapTable(content: string): Array<{ - id: string; - title: string; - risk: string; - depends: string[]; - done: boolean; - demo: string; -}> { - const results: Array<{ - id: string; - title: string; - risk: string; - depends: string[]; - done: boolean; - demo: string; - }> = []; - - // Try table format first: | S01 | Title | risk | depends | done-icon | demo | - const tableSection = content.match( - /## (?:Slice[s]?|Slice Overview|Slice Table)\s*\n([\s\S]*?)(?=\n##|\n$|$)/i, - ); - if (tableSection) { - const lines = tableSection[1].split("\n"); - for (const line of lines) { - if (!line.includes("|")) continue; - const cells = line - .split("|") - .map((c) => c.trim()) - .filter(Boolean); - if (cells.length < 4) continue; - if (cells[0] === "ID" || cells[0].startsWith("--")) continue; - - const id = cells[0].match(/S\d+/)?.[0]; - if (!id) continue; - - const done = cells.some( - (c) => c === "\u2611" || c === "\u2705" || c.toLowerCase() === "done", - ); - const depends = (cells[3] ?? "") - .replace(/\u2014/g, "") - .split(",") - .map((d) => d.trim()) - .filter(Boolean); - - results.push({ - id, - title: cells[1] ?? "", - risk: cells[2] ?? "medium", - depends, - done, - demo: cells[5] ?? "", - }); - } - if (results.length > 0) return results; - } - - // Try checkbox format: - [x] **S01: Title** `risk:high` `depends:[S01]` - const checkboxRe = - /^-\s+\[([ xX])\]\s+\*\*(S\d+):\s*(.+?)\*\*(?:.*?`risk:(\w+)`)?(?:.*?`depends:\[([^\]]*)\]`)?/gm; - let match: RegExpExecArray | null; - while ((match = checkboxRe.exec(content)) !== null) { - const [, checked, id, title, risk, deps] = match; - results.push({ - id, - title: title.trim(), - risk: risk ?? "medium", - depends: deps - ? deps - .split(",") - .map((d) => d.trim()) - .filter(Boolean) - : [], - done: checked !== " ", - demo: "", - }); - } - if (results.length > 0) return results; - - // Try prose headers: ## S01: Title - const headerRe = /^##\s+(S\d+):\s*(.+)/gm; - while ((match = headerRe.exec(content)) !== null) { - results.push({ - id: match[1], - title: match[2].trim(), - risk: "medium", - depends: [], - done: false, - demo: "", - }); - } - - return results; -} - -// --------------------------------------------------------------------------- -// PLAN.md task parser -// --------------------------------------------------------------------------- - -function parseSlicePlanTasks( - content: string, -): Array<{ id: string; title: string; done: boolean }> { - const results: Array<{ id: string; title: string; done: boolean }> = []; - - // Checkbox format: - [x] **T01: Title** — description - const taskRe = /^-\s+\[([ xX])\]\s+\*\*(T\d+):\s*(.+?)\*\*/gm; - let match: RegExpExecArray | null; - while ((match = taskRe.exec(content)) !== null) { - results.push({ - id: match[2], - title: match[3].trim(), - done: match[1] !== " ", - }); - } - if (results.length > 0) return results; - - // H3 format: ### T01: Title - const h3Re = /^###\s+(T\d+):\s*(.+)/gm; - while ((match = h3Re.exec(content)) !== null) { - results.push({ - id: match[1], - title: match[2].trim(), - done: false, - }); - } - - return results; -} - -// --------------------------------------------------------------------------- -// Milestone title from CONTEXT.md or ROADMAP.md H1 -// --------------------------------------------------------------------------- - -function readMilestoneTitle(sfRoot: string, mid: string): string { - const ctxPath = resolveMilestoneFile(sfRoot, mid, "CONTEXT"); - if (ctxPath && existsSync(ctxPath)) { - const content = readFileSync(ctxPath, "utf-8"); - const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m); - if (h1) return h1[1].trim(); - } - - const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP"); - if (roadmapPath && existsSync(roadmapPath)) { - const content = readFileSync(roadmapPath, "utf-8"); - const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m); - if (h1) return h1[1].trim(); - } - - return mid; -} - -function readVision(sfRoot: string, mid: string): string { - const roadmapPath = resolveMilestoneFile(sfRoot, mid, "ROADMAP"); - if (!roadmapPath || !existsSync(roadmapPath)) return ""; - - const content = readFileSync(roadmapPath, "utf-8"); - const section = content.match(/## Vision\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); - return section ? section[1].trim() : ""; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function readRoadmap( - projectDir: string, - filterMilestoneId?: string, -): RoadmapResult { - const sf = resolveSFRoot(projectDir); - let milestoneIds = findMilestoneIds(sf); - - if (filterMilestoneId) { - milestoneIds = milestoneIds.filter((id) => id === filterMilestoneId); - } - - const milestones: MilestoneInfo[] = []; - - for (const mid of milestoneIds) { - const title = readMilestoneTitle(sf, mid); - const vision = readVision(sf, mid); - - const summaryPath = resolveMilestoneFile(sf, mid, "SUMMARY"); - const hasSummary = summaryPath !== null && existsSync(summaryPath); - - const roadmapPath = resolveMilestoneFile(sf, mid, "ROADMAP"); - let roadmapSlices: ReturnType = []; - if (roadmapPath && existsSync(roadmapPath)) { - roadmapSlices = parseRoadmapTable(readFileSync(roadmapPath, "utf-8")); - } - - const fsSliceIds = findSliceIds(sf, mid); - const sliceIdSet = new Set([ - ...roadmapSlices.map((s) => s.id), - ...fsSliceIds, - ]); - - const slices: SliceInfo[] = []; - for (const sid of Array.from(sliceIdSet).sort()) { - const roadmapEntry = roadmapSlices.find((s) => s.id === sid); - const taskFiles = findTaskFiles(sf, mid, sid); - - const planPath = resolveSliceFile(sf, mid, sid, "PLAN"); - let planTasks: ReturnType = []; - if (planPath && existsSync(planPath)) { - planTasks = parseSlicePlanTasks(readFileSync(planPath, "utf-8")); - } - - const tasks: TaskInfo[] = []; - const seenIds = new Set(); - - for (const pt of planTasks) { - const fsTask = taskFiles.find((t) => t.id === pt.id); - const done = fsTask?.hasSummary ?? pt.done; - tasks.push({ - id: pt.id, - title: pt.title, - status: done ? "done" : "pending", - }); - seenIds.add(pt.id); - } - for (const ft of taskFiles) { - if (seenIds.has(ft.id)) continue; - tasks.push({ - id: ft.id, - title: ft.id, - status: ft.hasSummary ? "done" : "pending", - }); - } - - const allDone = - tasks.length > 0 && tasks.every((t) => t.status === "done"); - const anyDone = tasks.some((t) => t.status === "done"); - const sliceStatus: SliceInfo["status"] = allDone - ? "done" - : anyDone - ? "active" - : "pending"; - - slices.push({ - id: sid, - title: roadmapEntry?.title ?? sid, - status: sliceStatus, - risk: roadmapEntry?.risk ?? "medium", - depends: roadmapEntry?.depends ?? [], - demo: roadmapEntry?.demo ?? "", - tasks, - }); - } - - const allSlicesDone = - slices.length > 0 && slices.every((s) => s.status === "done"); - const anySliceActive = slices.some( - (s) => s.status === "active" || s.status === "done", - ); - const milestoneStatus: MilestoneInfo["status"] = hasSummary - ? "done" - : allSlicesDone - ? "done" - : anySliceActive - ? "active" - : "pending"; - - milestones.push({ - id: mid, - title, - status: milestoneStatus, - vision, - slices, - }); - } - - return { milestones }; -} diff --git a/packages/mcp-server/src/readers/state.ts b/packages/mcp-server/src/readers/state.ts deleted file mode 100644 index 47ebe5dae..000000000 --- a/packages/mcp-server/src/readers/state.ts +++ /dev/null @@ -1,259 +0,0 @@ -// SF MCP Server — project state reader -// Copyright (c) 2026 Jeremy McSpadden - -import { existsSync, readFileSync } from "node:fs"; -import { - findMilestoneIds, - findSliceIds, - findTaskFiles, - resolveRootFile, - resolveSFRoot, -} from "./paths.js"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ProgressResult { - activeMilestone: { id: string; title: string } | null; - activeSlice: { id: string; title: string } | null; - activeTask: { id: string; title: string } | null; - phase: string; - milestones: { - total: number; - done: number; - active: number; - pending: number; - parked: number; - }; - slices: { total: number; done: number; active: number; pending: number }; - tasks: { total: number; done: number; pending: number }; - requirements: { - active: number; - validated: number; - deferred: number; - outOfScope: number; - } | null; - blockers: string[]; - nextAction: string; -} - -// --------------------------------------------------------------------------- -// STATE.md parser -// --------------------------------------------------------------------------- - -function parseBoldField(content: string, label: string): string | null { - const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, "i"); - const m = content.match(re); - return m ? m[1].trim() : null; -} - -function parseActiveRef( - value: string | null, -): { id: string; title: string } | null { - if (!value || value.toLowerCase() === "none" || value === "—") return null; - // "M001: Flight Simulator" or "M001" - const m = value.match(/^(M\d+|S\d+|T\d+):?\s*(.*)/); - if (m) return { id: m[1], title: m[2] || m[1] }; - return { id: value, title: value }; -} - -function parsePhase(value: string | null): string { - if (!value) return "unknown"; - const lower = value.toLowerCase().trim(); - if (lower.includes("research") || lower.includes("discuss")) - return "research"; - if (lower.includes("plan")) return "plan"; - if (lower.includes("execut")) return "execute"; - if (lower.includes("complete") || lower.includes("done")) return "complete"; - return lower; -} - -function parseRequirementsLine( - value: string | null, -): ProgressResult["requirements"] | null { - if (!value) return null; - const active = value.match(/(\d+)\s*active/i); - const validated = value.match(/(\d+)\s*validated/i); - const deferred = value.match(/(\d+)\s*deferred/i); - const outOfScope = value.match(/(\d+)\s*out.of.scope/i); - if (!active && !validated && !deferred && !outOfScope) return null; - return { - active: active ? parseInt(active[1], 10) : 0, - validated: validated ? parseInt(validated[1], 10) : 0, - deferred: deferred ? parseInt(deferred[1], 10) : 0, - outOfScope: outOfScope ? parseInt(outOfScope[1], 10) : 0, - }; -} - -function parseBlockers(content: string): string[] { - const section = content.match(/## Blockers\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); - if (!section) return []; - return section[1] - .split("\n") - .map((l) => l.replace(/^[-*]\s*/, "").trim()) - .filter(Boolean); -} - -function parseNextAction(content: string): string { - const section = content.match(/## Next Action\s*\n([\s\S]*?)(?=\n##|\n$|$)/i); - if (!section) return ""; - return section[1].trim().split("\n")[0] || ""; -} - -// --------------------------------------------------------------------------- -// Milestone registry from STATE.md -// --------------------------------------------------------------------------- - -interface RegistryEntry { - id: string; - status: "done" | "active" | "pending" | "parked"; -} - -function parseMilestoneRegistry(content: string): RegistryEntry[] { - const section = content.match( - /## Milestone Registry\s*\n([\s\S]*?)(?=\n##|\n$|$)/i, - ); - if (!section) return []; - const entries: RegistryEntry[] = []; - for (const line of section[1].split("\n")) { - const m = line.match(/[-*]\s*(☑|✅|🔄|⬜|⏸)\s*\*\*(M\d+):\*\*/); - if (!m) continue; - const [, icon, id] = m; - let status: RegistryEntry["status"] = "pending"; - if (icon === "☑" || icon === "✅") status = "done"; - else if (icon === "🔄") status = "active"; - else if (icon === "⏸") status = "parked"; - entries.push({ id, status }); - } - return entries; -} - -// --------------------------------------------------------------------------- -// Count slices/tasks by walking filesystem -// --------------------------------------------------------------------------- - -function countSlicesAndTasks( - sfRoot: string, - milestoneIds: string[], -): { - slices: ProgressResult["slices"]; - tasks: ProgressResult["tasks"]; -} { - let sliceTotal = 0, - sliceDone = 0, - sliceActive = 0; - let taskTotal = 0, - taskDone = 0; - - for (const mid of milestoneIds) { - const sliceIds = findSliceIds(sfRoot, mid); - sliceTotal += sliceIds.length; - - for (const sid of sliceIds) { - const tasks = findTaskFiles(sfRoot, mid, sid); - taskTotal += tasks.length; - - const allDone = tasks.length > 0 && tasks.every((t) => t.hasSummary); - const anyDone = tasks.some((t) => t.hasSummary); - - if (allDone) { - sliceDone++; - taskDone += tasks.length; - } else { - if (anyDone) sliceActive++; - taskDone += tasks.filter((t) => t.hasSummary).length; - } - } - } - - return { - slices: { - total: sliceTotal, - done: sliceDone, - active: sliceActive, - pending: sliceTotal - sliceDone - sliceActive, - }, - tasks: { total: taskTotal, done: taskDone, pending: taskTotal - taskDone }, - }; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export function readProgress(projectDir: string): ProgressResult { - const sf = resolveSFRoot(projectDir); - const statePath = resolveRootFile(sf, "STATE.md"); - - // Defaults - const result: ProgressResult = { - activeMilestone: null, - activeSlice: null, - activeTask: null, - phase: "unknown", - milestones: { total: 0, done: 0, active: 0, pending: 0, parked: 0 }, - slices: { total: 0, done: 0, active: 0, pending: 0 }, - tasks: { total: 0, done: 0, pending: 0 }, - requirements: null, - blockers: [], - nextAction: "", - }; - - if (!existsSync(statePath)) { - // No STATE.md — derive from filesystem only - const milestoneIds = findMilestoneIds(sf); - result.milestones.total = milestoneIds.length; - result.milestones.pending = milestoneIds.length; - const counts = countSlicesAndTasks(sf, milestoneIds); - result.slices = counts.slices; - result.tasks = counts.tasks; - return result; - } - - const content = readFileSync(statePath, "utf-8"); - - // Parse STATE.md fields - result.activeMilestone = parseActiveRef( - parseBoldField(content, "Active Milestone"), - ); - result.activeSlice = parseActiveRef(parseBoldField(content, "Active Slice")); - result.activeTask = parseActiveRef(parseBoldField(content, "Active Task")); - result.phase = parsePhase(parseBoldField(content, "Phase")); - result.requirements = parseRequirementsLine( - parseBoldField(content, "Requirements Status"), - ); - result.blockers = parseBlockers(content); - result.nextAction = parseNextAction(content); - - // Milestone counts from registry - const registry = parseMilestoneRegistry(content); - if (registry.length > 0) { - result.milestones.total = registry.length; - result.milestones.done = registry.filter((e) => e.status === "done").length; - result.milestones.active = registry.filter( - (e) => e.status === "active", - ).length; - result.milestones.parked = registry.filter( - (e) => e.status === "parked", - ).length; - result.milestones.pending = - registry.length - - result.milestones.done - - result.milestones.active - - result.milestones.parked; - } else { - // Fallback: count directories - const milestoneIds = findMilestoneIds(sf); - result.milestones.total = milestoneIds.length; - result.milestones.pending = milestoneIds.length; - } - - // Slice/task counts from filesystem - const milestoneIds = findMilestoneIds(sf); - const counts = countSlicesAndTasks(sf, milestoneIds); - result.slices = counts.slices; - result.tasks = counts.tasks; - - return result; -} diff --git a/packages/mcp-server/src/secure-env-collect.test.ts b/packages/mcp-server/src/secure-env-collect.test.ts deleted file mode 100644 index 10cd7a1e2..000000000 --- a/packages/mcp-server/src/secure-env-collect.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// @singularity-forge/mcp-server — Tests for secure_env_collect MCP tool -// Copyright (c) 2026 Jeremy McSpadden -// -// Tests the secure_env_collect tool registered in createMcpServer. - -import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, it } from "vitest"; - -import { createMcpServer } from "./server.js"; -import { SessionManager } from "./session-manager.js"; - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -/** - * Since createMcpServer uses dynamic import for McpServer, we can't easily - * mock it. Instead, we test the env-writer utilities directly (in env-writer.test.ts) - * and test the tool integration by verifying: - * 1. The tool exists in the registered tools list - * 2. The handler produces correct results with mock data - * - * For handler-level testing, we create a standalone test that replicates - * the tool handler logic with a controllable mock. - */ - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), `${prefix}-`)); -} - -// --------------------------------------------------------------------------- -// Integration test — verify tool is registered -// --------------------------------------------------------------------------- - -describe("secure_env_collect tool registration", () => { - it("createMcpServer registers secure_env_collect tool", async () => { - // This test verifies the tool exists — createMcpServer internally calls - // server.tool('secure_env_collect', ...) which we can't intercept without - // module mocking, but we can verify the server creates successfully - const sm = new SessionManager(); - try { - const { server } = await createMcpServer(sm); - assert.ok(server, "server should be created"); - // The McpServer internally tracks registered tools — we verify no error - } finally { - await sm.cleanup(); - } - }); -}); - -// --------------------------------------------------------------------------- -// Handler logic tests — using env-writer directly to test the flow -// --------------------------------------------------------------------------- - -describe("secure_env_collect handler logic", () => { - it("skips keys that already exist in .env", async () => { - const tmp = makeTempDir("sec-collect"); - try { - const envPath = join(tmp, ".env"); - writeFileSync(envPath, "ALREADY_SET=existing-value\n"); - - // Import the utility directly to test the pre-check logic - const { checkExistingEnvKeys } = await import("./env-writer.js"); - const existing = await checkExistingEnvKeys( - ["ALREADY_SET", "NEW_KEY"], - envPath, - ); - assert.deepStrictEqual(existing, ["ALREADY_SET"]); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("writes collected values to .env without returning secret values", async () => { - const tmp = makeTempDir("sec-collect"); - try { - const envPath = join(tmp, ".env"); - const savedKey = process.env.SEC_COLLECT_TEST_KEY; - - const { applySecrets } = await import("./env-writer.js"); - const { applied, errors } = await applySecrets( - [{ key: "SEC_COLLECT_TEST_KEY", value: "super-secret-value" }], - "dotenv", - { envFilePath: envPath }, - ); - - assert.deepStrictEqual(applied, ["SEC_COLLECT_TEST_KEY"]); - assert.deepStrictEqual(errors, []); - - // Verify the value was written - const content = readFileSync(envPath, "utf8"); - assert.ok(content.includes("SEC_COLLECT_TEST_KEY=super-secret-value")); - - // Verify process.env was hydrated - assert.equal(process.env.SEC_COLLECT_TEST_KEY, "super-secret-value"); - - // Cleanup - if (savedKey === undefined) delete process.env.SEC_COLLECT_TEST_KEY; - else process.env.SEC_COLLECT_TEST_KEY = savedKey; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("auto-detects vercel destination from vercel.json", async () => { - const tmp = makeTempDir("sec-collect"); - try { - writeFileSync(join(tmp, "vercel.json"), "{}"); - const { detectDestination } = await import("./env-writer.js"); - assert.equal(detectDestination(tmp), "vercel"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("handles empty form values as skipped", async () => { - // Simulate what happens when user leaves a field empty in the form - const formContent: Record = { - API_KEY: "provided-value", - OPTIONAL_KEY: "", // empty = skip - }; - - const provided: Array<{ key: string; value: string }> = []; - const skipped: string[] = []; - - for (const [key, raw] of Object.entries(formContent)) { - const value = typeof raw === "string" ? raw.trim() : ""; - if (value.length > 0) { - provided.push({ key, value }); - } else { - skipped.push(key); - } - } - - assert.deepStrictEqual(provided, [ - { key: "API_KEY", value: "provided-value" }, - ]); - assert.deepStrictEqual(skipped, ["OPTIONAL_KEY"]); - }); - - it("result text never contains secret values", async () => { - const tmp = makeTempDir("sec-collect"); - try { - const envPath = join(tmp, ".env"); - const savedKey = process.env.RESULT_TEXT_TEST; - - const { applySecrets } = await import("./env-writer.js"); - const { applied } = await applySecrets( - [{ key: "RESULT_TEXT_TEST", value: "sk-super-secret-abc123" }], - "dotenv", - { envFilePath: envPath }, - ); - - // Simulate building result text (same logic as the tool handler) - const lines: string[] = [ - "destination: dotenv (auto-detected)", - ...applied.map((k) => `✓ ${k}: applied`), - ]; - const resultText = lines.join("\n"); - - // The result MUST NOT contain the secret value - assert.ok( - !resultText.includes("sk-super-secret-abc123"), - "result text must not contain secret value", - ); - assert.ok( - resultText.includes("RESULT_TEXT_TEST"), - "result text should contain key name", - ); - - // Cleanup - if (savedKey === undefined) delete process.env.RESULT_TEXT_TEST; - else process.env.RESULT_TEXT_TEST = savedKey; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); - - it("handles multiple keys with mixed existing/new/skipped", async () => { - const tmp = makeTempDir("sec-collect"); - try { - const envPath = join(tmp, ".env"); - writeFileSync(envPath, "EXISTING_A=already-here\n"); - const savedB = process.env.NEW_B; - const savedC = process.env.SKIP_C; - - const { checkExistingEnvKeys, applySecrets } = await import( - "./env-writer.js" - ); - - const allKeys = ["EXISTING_A", "NEW_B", "SKIP_C"]; - const existing = await checkExistingEnvKeys(allKeys, envPath); - assert.deepStrictEqual(existing, ["EXISTING_A"]); - - // Simulate form response: NEW_B has value, SKIP_C is empty - const formContent = { NEW_B: "new-value", SKIP_C: "" }; - const provided: Array<{ key: string; value: string }> = []; - const skipped: string[] = []; - - for (const key of allKeys.filter((k) => !existing.includes(k))) { - const raw = formContent[key as keyof typeof formContent] ?? ""; - if (raw.trim().length > 0) provided.push({ key, value: raw.trim() }); - else skipped.push(key); - } - - const { applied, errors } = await applySecrets(provided, "dotenv", { - envFilePath: envPath, - }); - - assert.deepStrictEqual(applied, ["NEW_B"]); - assert.deepStrictEqual(skipped, ["SKIP_C"]); - assert.deepStrictEqual(errors, []); - assert.deepStrictEqual(existing, ["EXISTING_A"]); - - // Cleanup - if (savedB === undefined) delete process.env.NEW_B; - else process.env.NEW_B = savedB; - if (savedC === undefined) delete process.env.SKIP_C; - else process.env.SKIP_C = savedC; - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts deleted file mode 100644 index 2b2ece820..000000000 --- a/packages/mcp-server/src/server.ts +++ /dev/null @@ -1,1084 +0,0 @@ -/** - * MCP Server — registers SF orchestration, project-state, and workflow tools. - * - * Session tools (6): sf_execute, sf_status, sf_result, sf_cancel, sf_query, sf_resolve_blocker - * Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation - * Read-only tools (6): sf_progress, sf_roadmap, sf_history, sf_doctor, sf_captures, sf_knowledge - * Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools - * - * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16 - * cannot resolve the SDK's subpath exports statically (same pattern as - * src/mcp-server.ts in the main package). - */ - -import { readdir, readFile, stat } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { - buildQuestionStructuredContent, - formatRoundResultForTool, - type Question, - type RoundResult, - roundResultFromElicitationContent, -} from "@singularity-forge/pi-agent-core"; -import { z } from "zod"; -import { - applySecrets, - checkExistingEnvKeys, - detectDestination, - resolveProjectEnvFilePath, -} from "./env-writer.js"; -import { readCaptures } from "./readers/captures.js"; -import { runDoctorLite } from "./readers/doctor-lite.js"; -import { - buildGraph, - graphDiff, - graphQuery, - graphStatus, - writeGraph, - writeSnapshot, -} from "./readers/graph.js"; -import { readKnowledge } from "./readers/knowledge.js"; -import { readHistory } from "./readers/metrics.js"; -import { resolveSFRoot } from "./readers/paths.js"; -import { readRoadmap } from "./readers/roadmap.js"; -import { readProgress } from "./readers/state.js"; -import type { SessionManager } from "./session-manager.js"; -import { registerWorkflowTools } from "./workflow-tools.js"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const MCP_PKG = "@modelcontextprotocol/sdk"; -const SERVER_NAME = "sf"; -const SERVER_VERSION = "2.53.0"; - -// --------------------------------------------------------------------------- -// Tool result helpers -// --------------------------------------------------------------------------- - -/** Wrap a JSON-serializable value as MCP tool content. */ -function jsonContent(data: unknown): { - content: Array<{ type: "text"; text: string }>; -} { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - }; -} - -/** Return an MCP error response. */ -function errorContent(message: string): { - isError: true; - content: Array<{ type: "text"; text: string }>; -} { - return { isError: true, content: [{ type: "text" as const, text: message }] }; -} - -/** Return raw text content without JSON wrapping. */ -function textContent(text: string): { - content: Array<{ type: "text"; text: string }>; -} { - return { content: [{ type: "text" as const, text }] }; -} - -function askUserQuestionsContent( - questions: AskUserQuestion[], - response: RoundResult | null, - cancelled: boolean, -): { - content: Array<{ type: "text"; text: string }>; - structuredContent: Record; -} { - return { - content: [ - { - type: "text" as const, - text: response - ? formatRoundResultForTool(response) - : "ask_user_questions was cancelled before receiving a response", - }, - ], - structuredContent: buildQuestionStructuredContent( - questions as Question[], - response, - cancelled, - ) as unknown as Record, - }; -} - -// --------------------------------------------------------------------------- -// sf_query filesystem reader -// --------------------------------------------------------------------------- - -/** - * Normalized query categories for {@link readProjectState}. - * - * Maps user-supplied query strings (or empty) to the set of fields we return. - * Accepts common synonyms so the MCP client can pass intuitive values. - */ -const QUERY_FIELDS = { - all: ["state", "project", "requirements", "milestones"] as const, - state: ["state"] as const, - status: ["state"] as const, - project: ["project"] as const, - requirements: ["requirements"] as const, - milestones: ["milestones"] as const, -} as const; - -type QueryCategory = keyof typeof QUERY_FIELDS; -type ProjectStateField = (typeof QUERY_FIELDS)[QueryCategory][number]; - -function normalizeQuery(query: string | undefined): QueryCategory { - const key = (query ?? "all").trim().toLowerCase(); - if (key in QUERY_FIELDS) return key as QueryCategory; - return "all"; -} - -async function readProjectState( - projectDir: string, - query: string | undefined, -): Promise> { - const sfDir = join(resolve(projectDir), ".sf"); - const category = normalizeQuery(query); - const wanted = new Set(QUERY_FIELDS[category]); - - const result: Record = { - projectDir: resolve(projectDir), - query: category, - }; - - if (wanted.has("state")) { - try { - result.state = await readFile(join(sfDir, "STATE.md"), "utf-8"); - } catch { - result.state = null; - } - } - - if (wanted.has("project")) { - try { - result.project = await readFile(join(sfDir, "PROJECT.md"), "utf-8"); - } catch { - result.project = null; - } - } - - if (wanted.has("requirements")) { - try { - result.requirements = await readFile( - join(sfDir, "REQUIREMENTS.md"), - "utf-8", - ); - } catch { - result.requirements = null; - } - } - - if (wanted.has("milestones")) { - const milestonesDir = join(sfDir, "milestones"); - try { - const entries = await readdir(milestonesDir, { withFileTypes: true }); - const milestones: Array<{ - id: string; - hasRoadmap: boolean; - hasSummary: boolean; - }> = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const mDir = join(milestonesDir, entry.name); - const hasRoadmap = await fileExists( - join(mDir, `${entry.name}-ROADMAP.md`), - ); - const hasSummary = await fileExists( - join(mDir, `${entry.name}-SUMMARY.md`), - ); - milestones.push({ id: entry.name, hasRoadmap, hasSummary }); - } - result.milestones = milestones; - } catch { - result.milestones = []; - } - } - - return result; -} - -async function fileExists(path: string): Promise { - try { - await stat(path); - return true; - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// MCP Server type — minimal interface for the dynamically-imported McpServer -// --------------------------------------------------------------------------- - -interface ElicitRequestFormParams { - mode?: "form"; - message: string; - requestedSchema: { - type: "object"; - properties: Record>; - required?: string[]; - }; -} - -/** - * Handler extra — the second argument passed by McpServer.tool handlers. - * Contains an AbortSignal scoped to the JSON-RPC request (cancelled when - * the client cancels the `tools/call`) plus other per-request metadata. - * Tools that can actually be stopped mid-flight should honour `signal`. - */ -export interface McpToolExtra { - signal?: AbortSignal; - requestId?: string | number; - sendNotification?: (notification: unknown) => void | Promise; -} - -interface McpServerInstance { - tool( - name: string, - description: string, - params: Record, - handler: ( - args: Record, - extra?: McpToolExtra, - ) => Promise, - ): unknown; - server: { - elicitInput( - params: AskUserQuestionsElicitRequest | ElicitRequestFormParams, - options?: unknown, - ): Promise; - }; - connect(transport: unknown): Promise; - close(): Promise; -} - -interface AskUserQuestionOption { - label: string; - description: string; -} - -interface AskUserQuestion { - id: string; - header: string; - question: string; - options: AskUserQuestionOption[]; - allowMultiple?: boolean; -} - -interface AskUserQuestionsParams { - questions: AskUserQuestion[]; -} - -type AskUserQuestionsContentValue = string | number | boolean | string[]; - -interface AskUserQuestionsElicitResult { - action: "accept" | "decline" | "cancel"; - content?: Record; -} - -interface AskUserQuestionsElicitRequest { - mode: "form"; - message: string; - requestedSchema: { - type: "object"; - properties: Record>; - required?: string[]; - }; -} - -const OTHER_OPTION_LABEL = "None of the above"; - -function validateAskUserQuestionsPayload( - questions: AskUserQuestion[], -): string | null { - if (questions.length === 0 || questions.length > 3) { - return "Error: questions must contain 1-3 items"; - } - - for (const question of questions) { - if (!question.options || question.options.length === 0) { - return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`; - } - } - - return null; -} - -export function buildAskUserQuestionsElicitRequest( - questions: AskUserQuestion[], -): AskUserQuestionsElicitRequest { - const properties: Record> = {}; - const required = questions.map((question) => question.id); - - for (const question of questions) { - if (question.allowMultiple) { - properties[question.id] = { - type: "array", - title: question.header, - description: question.question, - minItems: 1, - maxItems: question.options.length, - items: { - anyOf: question.options.map((option) => ({ - const: option.label, - title: option.label, - })), - }, - }; - continue; - } - - properties[question.id] = { - type: "string", - title: question.header, - description: question.question, - oneOf: [ - ...question.options, - { - label: OTHER_OPTION_LABEL, - description: "Choose this when the listed options do not fit.", - }, - ].map((option) => ({ - const: option.label, - title: option.label, - })), - }; - - properties[`${question.id}__note`] = { - type: "string", - title: `${question.header} Note`, - description: `Optional note for "${OTHER_OPTION_LABEL}".`, - maxLength: 500, - }; - } - - return { - mode: "form", - message: - 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.', - requestedSchema: { - type: "object", - properties, - required, - }, - }; -} - -export function formatAskUserQuestionsElicitResult( - questions: AskUserQuestion[], - result: AskUserQuestionsElicitResult, -): string { - return formatRoundResultForTool( - buildAskUserQuestionsRoundResult(questions, result), - ); -} - -export function buildAskUserQuestionsRoundResult( - questions: AskUserQuestion[], - result: AskUserQuestionsElicitResult, -): RoundResult { - return roundResultFromElicitationContent( - questions as Question[], - result, - OTHER_OPTION_LABEL, - ); -} - -// --------------------------------------------------------------------------- -// createMcpServer -// --------------------------------------------------------------------------- - -/** - * Create and configure an MCP server with session, read-only, and workflow tools. - * - * Returns the McpServer instance — call `connect(transport)` to start serving. - * Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues. - */ -export async function createMcpServer(sessionManager: SessionManager): Promise<{ - server: McpServerInstance; -}> { - // Dynamic import — same workaround as src/mcp-server.ts - const mcpMod = await import(`${MCP_PKG}/server/mcp.js`); - const McpServer = mcpMod.McpServer; - - const server: McpServerInstance = new McpServer( - { name: SERVER_NAME, version: SERVER_VERSION }, - { capabilities: { tools: {}, elicitation: {} } }, - ); - - // ----------------------------------------------------------------------- - // sf_execute — start a new SF auto-mode session. - // - // If the JSON-RPC request is aborted while the session is starting (or - // immediately after), we cancel the session so we don't leak a background - // RpcClient process. Once the session is running the caller should use - // `sf_cancel` to stop it via sessionId. - // ----------------------------------------------------------------------- - server.tool( - "sf_execute", - "Start a SF auto-mode session for a project directory. Returns a sessionId for tracking.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - command: z - .string() - .optional() - .describe('Command to send (default: "/sf autonomous")'), - model: z.string().optional().describe("Model ID override"), - bare: z - .boolean() - .optional() - .describe("Run in bare mode (skip user config)"), - }, - async (args: Record, extra?: McpToolExtra) => { - const { projectDir, command, model, bare } = args as { - projectDir: string; - command?: string; - model?: string; - bare?: boolean; - }; - try { - const sessionId = await sessionManager.startSession(projectDir, { - command, - model, - bare, - }); - - // If the client aborted while startSession was running, cancel the - // newly-created session rather than leaving an orphaned process. - if (extra?.signal?.aborted) { - await sessionManager.cancelSession(sessionId).catch(() => { - /* swallow */ - }); - return errorContent("sf_execute aborted by client before returning"); - } - - return jsonContent({ sessionId, status: "started" }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_status — poll session status - // ----------------------------------------------------------------------- - server.tool( - "sf_status", - "Get the current status of a SF session including progress, recent events, and pending blockers.", - { - sessionId: z.string().describe("Session ID returned from sf_execute"), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - const session = sessionManager.getSession(sessionId); - if (!session) return errorContent(`Session not found: ${sessionId}`); - - const durationMs = Date.now() - session.startTime; - const toolCallCount = session.events.filter( - (e) => - (e as Record).type === "tool_use" || - (e as Record).type === "tool_execution_start", - ).length; - - return jsonContent({ - status: session.status, - progress: { - eventCount: session.events.length, - toolCalls: toolCallCount, - }, - recentEvents: session.events.slice(-10), - pendingBlocker: session.pendingBlocker - ? { - id: session.pendingBlocker.id, - method: session.pendingBlocker.method, - message: session.pendingBlocker.message, - } - : null, - cost: session.cost, - durationMs, - }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_result — get accumulated session result - // ----------------------------------------------------------------------- - server.tool( - "sf_result", - "Get the result of a SF session. Returns partial results if the session is still running.", - { - sessionId: z.string().describe("Session ID returned from sf_execute"), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - const result = sessionManager.getResult(sessionId); - return jsonContent(result); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_cancel — cancel a running session - // ----------------------------------------------------------------------- - server.tool( - "sf_cancel", - "Cancel a running SF session. Aborts the current operation and stops the process.", - { - sessionId: z.string().describe("Session ID returned from sf_execute"), - }, - async (args: Record) => { - const { sessionId } = args as { sessionId: string }; - try { - await sessionManager.cancelSession(sessionId); - return jsonContent({ cancelled: true }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_query — read project state from filesystem (no session needed). - // - // `query` is optional: when omitted the tool returns all fields (STATE.md, - // PROJECT.md, requirements, milestone listing). Accepted narrow values: - // "state" / "status", "project", "requirements", "milestones", "all". - // Unknown values fall back to "all" for forward-compatibility. - // ----------------------------------------------------------------------- - server.tool( - "sf_query", - 'Query SF project state from the filesystem. By default returns STATE.md, PROJECT.md, requirements, and milestone listing. Pass `query` to narrow the response (accepted: "state"/"status", "project", "requirements", "milestones", "all"). Does not require an active session.', - { - projectDir: z.string().describe("Absolute path to the project directory"), - query: z - .enum([ - "all", - "state", - "status", - "project", - "requirements", - "milestones", - ]) - .optional() - .describe('Narrow the response to a single field (default: "all")'), - }, - async (args: Record) => { - const { projectDir, query } = args as { - projectDir: string; - query?: string; - }; - try { - const state = await readProjectState(projectDir, query); - return jsonContent(state); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_resolve_blocker — resolve a pending blocker - // ----------------------------------------------------------------------- - server.tool( - "sf_resolve_blocker", - "Resolve a pending blocker in a SF session by sending a response to the UI request.", - { - sessionId: z.string().describe("Session ID returned from sf_execute"), - response: z.string().describe("Response to send for the pending blocker"), - }, - async (args: Record) => { - const { sessionId, response } = args as { - sessionId: string; - response: string; - }; - try { - await sessionManager.resolveBlocker(sessionId, response); - return jsonContent({ resolved: true }); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // ask_user_questions — structured user input via MCP form elicitation - // ----------------------------------------------------------------------- - server.tool( - "ask_user_questions", - 'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.', - { - questions: z - .array( - z.object({ - id: z - .string() - .describe("Stable identifier for mapping answers (snake_case)"), - header: z - .string() - .describe( - "Short header label shown in the UI (12 or fewer chars)", - ), - question: z - .string() - .describe("Single-sentence prompt shown to the user"), - options: z - .array( - z.object({ - label: z.string().describe("User-facing label (1-5 words)"), - description: z - .string() - .describe( - "One short sentence explaining impact/tradeoff if selected", - ), - }), - ) - .describe( - 'Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.', - ), - allowMultiple: z - .boolean() - .optional() - .describe( - 'If true, the user can select multiple options. No "None of the above" option is added.', - ), - }), - ) - .describe("Questions to show the user. Prefer 1 and do not exceed 3."), - }, - async (args: Record) => { - const { questions } = args as unknown as AskUserQuestionsParams; - try { - const validationError = validateAskUserQuestionsPayload(questions); - if (validationError) return errorContent(validationError); - - const elicitation = await server.server.elicitInput( - buildAskUserQuestionsElicitRequest(questions), - ); - if (elicitation.action !== "accept" || !elicitation.content) { - return askUserQuestionsContent(questions, null, true); - } - - return askUserQuestionsContent( - questions, - buildAskUserQuestionsRoundResult(questions, elicitation), - false, - ); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // secure_env_collect — collect secrets via MCP form elicitation - // ----------------------------------------------------------------------- - server.tool( - "secure_env_collect", - "Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - keys: z - .array( - z.object({ - key: z.string().describe("Env var name, e.g. OPENAI_API_KEY"), - hint: z - .string() - .optional() - .describe('Format hint shown to user, e.g. "starts with sk-"'), - guidance: z - .array(z.string()) - .optional() - .describe("Step-by-step instructions for obtaining this key"), - }), - ) - .min(1) - .describe("Environment variables to collect"), - destination: z - .enum(["dotenv", "vercel", "convex"]) - .optional() - .describe( - "Where to write secrets. Auto-detected from project files if omitted.", - ), - envFilePath: z - .string() - .optional() - .describe( - "Path to .env file (dotenv only). Defaults to .env in projectDir.", - ), - environment: z - .enum(["development", "preview", "production"]) - .optional() - .describe("Target environment (vercel/convex only)"), - }, - async (args: Record) => { - const { projectDir, keys, destination, envFilePath, environment } = - args as { - projectDir: string; - keys: Array<{ key: string; hint?: string; guidance?: string[] }>; - destination?: "dotenv" | "vercel" | "convex"; - envFilePath?: string; - environment?: "development" | "preview" | "production"; - }; - - try { - const resolvedProjectDir = resolveProjectEnvFilePath(projectDir); - const resolvedEnvPath = resolve( - resolvedProjectDir, - envFilePath ?? ".env", - ); - - // (1) Check which keys already exist - const allKeyNames = keys.map((k) => k.key); - const existingKeys = await checkExistingEnvKeys( - allKeyNames, - resolvedEnvPath, - ); - const existingSet = new Set(existingKeys); - const pendingKeys = keys.filter((k) => !existingSet.has(k.key)); - - // If all keys already exist, return immediately - if (pendingKeys.length === 0) { - const lines = existingKeys.map((k) => `• ${k}: already set`); - return textContent( - `All ${existingKeys.length} key(s) already set.\n${lines.join("\n")}`, - ); - } - - // (2) Build elicitation form — one string field per pending key - const properties: Record> = {}; - const required: string[] = []; - - for (const item of pendingKeys) { - const descParts: string[] = []; - if (item.hint) descParts.push(`Format: ${item.hint}`); - if (item.guidance && item.guidance.length > 0) { - descParts.push("How to get this:"); - item.guidance.forEach((step, i) => - descParts.push(`${i + 1}. ${step}`), - ); - } - descParts.push("Leave empty to skip."); - - properties[item.key] = { - type: "string", - title: item.key, - description: descParts.join("\n"), - }; - // Don't mark as required — empty string = skip - } - - // (3) Elicit input from the MCP client - const elicitation = await server.server.elicitInput({ - message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`, - requestedSchema: { - type: "object", - properties, - required, - }, - }); - - if (elicitation.action !== "accept" || !elicitation.content) { - return textContent("secure_env_collect was cancelled by user."); - } - - // (4) Separate provided vs skipped from form response - const provided: Array<{ key: string; value: string }> = []; - const skipped: string[] = []; - - for (const item of pendingKeys) { - const raw = elicitation.content[item.key]; - const value = typeof raw === "string" ? raw.trim() : ""; - if (value.length > 0) { - provided.push({ key: item.key, value }); - } else { - skipped.push(item.key); - } - } - - // (5) Auto-detect destination if not specified - const resolvedDestination = - destination ?? detectDestination(resolvedProjectDir); - - // (6) Write secrets to destination - const { applied, errors } = await applySecrets( - provided, - resolvedDestination, - { - envFilePath: resolvedEnvPath, - environment, - }, - ); - - // (7) Build result — NEVER include secret values - const lines: string[] = [ - `destination: ${resolvedDestination}${!destination ? " (auto-detected)" : ""}${environment ? ` (${environment})` : ""}`, - ]; - for (const k of applied) lines.push(`✓ ${k}: applied`); - for (const k of skipped) lines.push(`• ${k}: skipped`); - for (const k of existingKeys) lines.push(`• ${k}: already set`); - for (const e of errors) lines.push(`✗ ${e}`); - - return errors.length > 0 && applied.length === 0 - ? errorContent(lines.join("\n")) - : textContent(lines.join("\n")); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ======================================================================= - // READ-ONLY TOOLS — no session required, pure filesystem reads - // ======================================================================= - - // ----------------------------------------------------------------------- - // sf_progress — structured project progress metrics - // ----------------------------------------------------------------------- - server.tool( - "sf_progress", - "Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .sf/ on disk.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - }, - async (args: Record) => { - const { projectDir } = args as { projectDir: string }; - try { - return jsonContent(readProgress(projectDir)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_roadmap — milestone/slice/task structure with status - // ----------------------------------------------------------------------- - server.tool( - "sf_roadmap", - "Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - milestoneId: z - .string() - .optional() - .describe('Filter to a specific milestone (e.g. "M001")'), - }, - async (args: Record) => { - const { projectDir, milestoneId } = args as { - projectDir: string; - milestoneId?: string; - }; - try { - return jsonContent(readRoadmap(projectDir, milestoneId)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_history — execution history with cost/token metrics - // ----------------------------------------------------------------------- - server.tool( - "sf_history", - "Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - limit: z - .number() - .optional() - .describe("Max entries to return (most recent first). Default: all."), - }, - async (args: Record) => { - const { projectDir, limit } = args as { - projectDir: string; - limit?: number; - }; - try { - return jsonContent(readHistory(projectDir, limit)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_doctor — lightweight structural health check - // ----------------------------------------------------------------------- - server.tool( - "sf_doctor", - "Run a lightweight structural health check on the .sf/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - scope: z - .string() - .optional() - .describe('Limit checks to a specific milestone (e.g. "M001")'), - }, - async (args: Record) => { - const { projectDir, scope } = args as { - projectDir: string; - scope?: string; - }; - try { - return jsonContent(runDoctorLite(projectDir, scope)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_captures — pending captures and ideas - // ----------------------------------------------------------------------- - server.tool( - "sf_captures", - "Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - filter: z - .enum(["all", "pending", "actionable"]) - .optional() - .describe('Filter captures (default: "all")'), - }, - async (args: Record) => { - const { projectDir, filter } = args as { - projectDir: string; - filter?: "all" | "pending" | "actionable"; - }; - try { - return jsonContent(readCaptures(projectDir, filter ?? "all")); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_knowledge — project knowledge base - // ----------------------------------------------------------------------- - server.tool( - "sf_knowledge", - "Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.", - { - projectDir: z.string().describe("Absolute path to the project directory"), - }, - async (args: Record) => { - const { projectDir } = args as { projectDir: string }; - try { - return jsonContent(readKnowledge(projectDir)); - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - // ----------------------------------------------------------------------- - // sf_graph — knowledge graph for SF projects - // - // Modes: - // build Parse .sf/ artifacts and write graph.json atomically. - // query Search the graph for nodes matching a term (BFS, budget-trimmed). - // status Check whether graph.json exists and whether it is stale (>24h). - // diff Compare graph.json with the last build snapshot. - // ----------------------------------------------------------------------- - server.tool( - "sf_graph", - [ - "Manage the SF project knowledge graph. No session required.", - "", - "Modes:", - " build Parse .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,", - " KNOWLEDGE.md) and write .sf/graphs/graph.json atomically.", - " query Search graph nodes by term (BFS from seed matches, budget-trimmed).", - " Returns matching nodes and reachable edges within the token budget.", - " status Show whether graph.json exists, its age, node/edge counts, and", - " whether it is stale (built more than 24 hours ago).", - " diff Compare current graph.json with .last-build-snapshot.json.", - " Returns added, removed, and changed nodes and edges.", - ].join("\n"), - { - projectDir: z.string().describe("Absolute path to the project directory"), - mode: z - .enum(["build", "query", "status", "diff"]) - .describe("Operation: build | query | status | diff"), - term: z - .string() - .optional() - .describe("Search term for query mode (case-insensitive)"), - budget: z - .number() - .optional() - .describe("Token budget for query mode (default: 4000)"), - snapshot: z - .boolean() - .optional() - .describe("Write snapshot before build (for future diff)"), - }, - async (args: Record) => { - const { projectDir, mode, term, budget, snapshot } = args as { - projectDir: string; - mode: "build" | "query" | "status" | "diff"; - term?: string; - budget?: number; - snapshot?: boolean; - }; - - try { - const sfRoot = resolveSFRoot(projectDir); - - switch (mode) { - case "build": { - if (snapshot) { - await writeSnapshot(sfRoot).catch(() => { - /* best-effort */ - }); - } - const graph = await buildGraph(projectDir); - await writeGraph(sfRoot, graph); - return jsonContent({ - built: true, - nodeCount: graph.nodes.length, - edgeCount: graph.edges.length, - builtAt: graph.builtAt, - }); - } - - case "query": { - const result = await graphQuery(projectDir, term ?? "", budget); - return jsonContent(result); - } - - case "status": { - const result = await graphStatus(projectDir); - return jsonContent(result); - } - - case "diff": { - const result = await graphDiff(projectDir); - return jsonContent(result); - } - } - } catch (err) { - return errorContent(err instanceof Error ? err.message : String(err)); - } - }, - ); - - registerWorkflowTools(server); - - return { server }; -} diff --git a/packages/mcp-server/src/session-manager.ts b/packages/mcp-server/src/session-manager.ts deleted file mode 100644 index f7223dc1a..000000000 --- a/packages/mcp-server/src/session-manager.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * SessionManager — manages RpcClient lifecycle for background SF execution. - * - * One active session per projectDir. Tracks events in a ring buffer, - * detects blockers, tracks terminal state, and accumulates cost using - * the cumulative-max pattern (K004). - */ - -import { execSync } from "node:child_process"; -import { resolve } from "node:path"; -import type { - RpcCostUpdateEvent, - RpcExtensionUIRequest, - RpcInitResult, - SdkAgentEvent, -} from "@singularity-forge/rpc-client"; -import { RpcClient } from "@singularity-forge/rpc-client"; -import type { - ExecuteOptions, - ManagedSession, - PendingBlocker, -} from "./types.js"; -import { INIT_TIMEOUT_MS, MAX_EVENTS } from "./types.js"; - -// --------------------------------------------------------------------------- -// Inlined detection logic (from headless-events.ts — no internal package imports) -// --------------------------------------------------------------------------- - -const FIRE_AND_FORGET_METHODS = new Set([ - "notify", - "setStatus", - "setWidget", - "setTitle", - "set_editor_text", -]); - -const TERMINAL_PREFIXES = ["auto-mode stopped", "step-mode stopped"]; - -function isTerminalNotification(event: Record): boolean { - if (event.type !== "extension_ui_request" || event.method !== "notify") - return false; - const message = String(event.message ?? "").toLowerCase(); - return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)); -} - -function isBlockedNotification(event: Record): boolean { - if (event.type !== "extension_ui_request" || event.method !== "notify") - return false; - const message = String(event.message ?? "").toLowerCase(); - return message.includes("blocked:"); -} - -function isBlockingUIRequest(event: Record): boolean { - if (event.type !== "extension_ui_request") return false; - const method = String(event.method ?? ""); - return !FIRE_AND_FORGET_METHODS.has(method); -} - -// --------------------------------------------------------------------------- -// SessionManager -// --------------------------------------------------------------------------- - -export class SessionManager { - /** Sessions keyed by projectDir for duplicate-start prevention */ - private sessions = new Map(); - - /** - * Start a new SF auto-mode session for the given project directory. - * - * Rejects if a session already exists for this projectDir. - * Creates an RpcClient, starts the process, performs the v2 init handshake, - * wires event tracking, and sends '/sf autonomous' to begin execution. - */ - async startSession( - projectDir: string, - options: ExecuteOptions = {}, - ): Promise { - if (!projectDir || projectDir.trim() === "") { - throw new Error("projectDir is required and cannot be empty"); - } - - const resolvedDir = resolve(projectDir); - - const existing = this.sessions.get(resolvedDir); - if (existing) { - throw new Error( - `Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`, - ); - } - - const cliPath = options.cliPath ?? SessionManager.resolveCLIPath(); - - const args: string[] = ["--mode", "rpc"]; - if (options.model) args.push("--model", options.model); - if (options.bare) args.push("--bare"); - - const client = new RpcClient({ - cliPath, - cwd: resolvedDir, - args, - }); - - // Build the session shell before async operations so we can track state - const session: ManagedSession = { - sessionId: "", // filled after init - projectDir: resolvedDir, - status: "starting", - client, - events: [], - pendingBlocker: null, - cost: { - totalCost: 0, - tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - startTime: Date.now(), - }; - - // Insert into map early (keyed by dir) so concurrent starts are rejected - this.sessions.set(resolvedDir, session); - - try { - // Start the process with timeout - await Promise.race([ - client.start(), - timeout( - INIT_TIMEOUT_MS, - `RpcClient.start() timed out after ${INIT_TIMEOUT_MS}ms`, - ), - ]); - - // Perform v2 init handshake - const initResult: RpcInitResult = (await Promise.race([ - client.init(), - timeout( - INIT_TIMEOUT_MS, - `RpcClient.init() timed out after ${INIT_TIMEOUT_MS}ms`, - ), - ])) as RpcInitResult; - - session.sessionId = initResult.sessionId; - session.status = "running"; - - // Wire event tracking - session.unsubscribe = client.onEvent((event: SdkAgentEvent) => { - this.handleEvent(session, event); - }); - - // Kick off autonomous mode - const command = options.command ?? "/sf autonomous"; - await client.prompt(command); - - return session.sessionId; - } catch (err) { - session.status = "error"; - session.error = err instanceof Error ? err.message : String(err); - - // Attempt cleanup - try { - await client.stop(); - } catch { - /* swallow cleanup errors */ - } - - // Keep session in map so callers can inspect the error - throw new Error( - `Failed to start session for ${resolvedDir}: ${session.error}`, - ); - } - } - - /** - * Look up a session by sessionId. - * Linear scan is fine — we expect <10 concurrent sessions. - */ - getSession(sessionId: string): ManagedSession | undefined { - for (const session of this.sessions.values()) { - if (session.sessionId === sessionId) return session; - } - return undefined; - } - - /** - * Look up a session by project directory (direct map lookup). - */ - getSessionByDir(projectDir: string): ManagedSession | undefined { - return this.sessions.get(resolve(projectDir)); - } - - /** - * Resolve a pending blocker by sending a UI response. - */ - async resolveBlocker(sessionId: string, response: string): Promise { - const session = this.getSession(sessionId); - if (!session) throw new Error(`Session not found: ${sessionId}`); - if (!session.pendingBlocker) - throw new Error(`No pending blocker for session ${sessionId}`); - - const blocker = session.pendingBlocker; - session.client.sendUIResponse(blocker.id, { value: response }); - session.pendingBlocker = null; - if (session.status === "blocked") { - session.status = "running"; - } - } - - /** - * Cancel a running session — abort current operation then stop the process. - */ - async cancelSession(sessionId: string): Promise { - const session = this.getSession(sessionId); - if (!session) throw new Error(`Session not found: ${sessionId}`); - - try { - await session.client.abort(); - } catch { - /* may already be stopped */ - } - - try { - await session.client.stop(); - } catch { - /* swallow */ - } - - session.status = "cancelled"; - session.unsubscribe?.(); - } - - /** - * Build a HeadlessJsonResult-shaped object from accumulated session state. - */ - getResult(sessionId: string): Record { - const session = this.getSession(sessionId); - if (!session) throw new Error(`Session not found: ${sessionId}`); - - const durationMs = Date.now() - session.startTime; - - return { - sessionId: session.sessionId, - projectDir: session.projectDir, - status: session.status, - durationMs, - cost: session.cost, - recentEvents: session.events.slice(-10), - pendingBlocker: session.pendingBlocker - ? { - id: session.pendingBlocker.id, - method: session.pendingBlocker.method, - message: session.pendingBlocker.message, - } - : null, - error: session.error ?? null, - }; - } - - /** - * Stop all active sessions and clean up resources. - */ - async cleanup(): Promise { - const stopPromises: Promise[] = []; - - for (const session of this.sessions.values()) { - session.unsubscribe?.(); - if ( - session.status === "running" || - session.status === "starting" || - session.status === "blocked" - ) { - stopPromises.push( - session.client.stop().catch(() => { - /* swallow */ - }), - ); - session.status = "cancelled"; - } - } - - await Promise.allSettled(stopPromises); - } - - /** - * Resolve the SF CLI path. - * - * 1. SF_CLI_PATH env var (highest priority) - * 2. `which sf` → resolve to the actual dist/cli.js - */ - static resolveCLIPath(): string { - // Check env var first - const envPath = process.env["SF_CLI_PATH"]; - if (envPath) return resolve(envPath); - - // Fallback: locate `sf` via which - try { - const sfBin = execSync("which sf", { encoding: "utf-8" }).trim(); - if (sfBin) { - // sf bin is typically a symlink to dist/loader.js — return the resolved path - return resolve(sfBin); - } - } catch { - // which failed - } - - throw new Error( - "Cannot find SF CLI. Set SF_CLI_PATH environment variable or ensure `sf` is in PATH.", - ); - } - - // --------------------------------------------------------------------------- - // Private: Event Handling - // --------------------------------------------------------------------------- - - private handleEvent(session: ManagedSession, event: SdkAgentEvent): void { - // Ring buffer: push and trim - session.events.push(event); - if (session.events.length > MAX_EVENTS) { - session.events.splice(0, session.events.length - MAX_EVENTS); - } - - // Cost tracking (K004 — cumulative-max) - if (event.type === "cost_update") { - const costEvent = event as unknown as RpcCostUpdateEvent; - session.cost.totalCost = Math.max( - session.cost.totalCost, - costEvent.cumulativeCost ?? 0, - ); - if (costEvent.tokens) { - session.cost.tokens.input = Math.max( - session.cost.tokens.input, - costEvent.tokens.input ?? 0, - ); - session.cost.tokens.output = Math.max( - session.cost.tokens.output, - costEvent.tokens.output ?? 0, - ); - session.cost.tokens.cacheRead = Math.max( - session.cost.tokens.cacheRead, - costEvent.tokens.cacheRead ?? 0, - ); - session.cost.tokens.cacheWrite = Math.max( - session.cost.tokens.cacheWrite, - costEvent.tokens.cacheWrite ?? 0, - ); - } - } - - // Terminal detection — auto-mode/step-mode stopped - if (isTerminalNotification(event as Record)) { - // Check if it's a blocked stop (not truly terminal — it's a blocker) - if (isBlockedNotification(event as Record)) { - session.status = "blocked"; - session.pendingBlocker = extractBlocker(event); - } else { - session.status = "completed"; - session.unsubscribe?.(); - } - return; - } - - // Blocker detection — non-fire-and-forget extension_ui_request - if (isBlockingUIRequest(event as Record)) { - session.status = "blocked"; - session.pendingBlocker = extractBlocker(event); - } - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function timeout(ms: number, message: string): Promise { - return new Promise((_, reject) => { - setTimeout(() => reject(new Error(message)), ms); - }); -} - -function extractBlocker(event: SdkAgentEvent): PendingBlocker { - const uiEvent = event as unknown as RpcExtensionUIRequest; - return { - id: String(uiEvent.id ?? ""), - method: String(uiEvent.method ?? ""), - message: String( - (uiEvent as Record).title ?? - (uiEvent as Record).message ?? - "", - ), - event: uiEvent, - }; -} diff --git a/packages/mcp-server/src/tool-credentials.test.ts b/packages/mcp-server/src/tool-credentials.test.ts deleted file mode 100644 index 7c8803dc0..000000000 --- a/packages/mcp-server/src/tool-credentials.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, it } from "vitest"; - -import { - loadStoredCredentialEnvKeys, - resolveAuthPath, -} from "./tool-credentials.js"; - -describe("tool credentials", () => { - it("hydrates supported model and tool keys from auth.json", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-")); - const authPath = join(tempRoot, "auth.json"); - const env: NodeJS.ProcessEnv = {}; - - try { - writeFileSync( - authPath, - JSON.stringify({ - anthropic: { type: "api_key", key: "sk-ant-secret" }, - openai: { type: "api_key", key: "sk-openai-secret" }, - xiaomi: { type: "api_key", key: "xiaomi-secret" }, - tavily: { type: "api_key", key: "tvly-secret" }, - serper: { type: "api_key", key: "serper-secret" }, - exa: { type: "api_key", key: "exa-secret" }, - context7: [{ type: "api_key", key: "ctx7-secret" }], - }), - ); - - const loaded = loadStoredCredentialEnvKeys({ authPath, env }); - assert.deepEqual(loaded.sort(), [ - "ANTHROPIC_API_KEY", - "CONTEXT7_API_KEY", - "EXA_API_KEY", - "OPENAI_API_KEY", - "SERPER_API_KEY", - "TAVILY_API_KEY", - "XIAOMI_API_KEY", - ]); - assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-secret"); - assert.equal(env.OPENAI_API_KEY, "sk-openai-secret"); - assert.equal(env.TAVILY_API_KEY, "tvly-secret"); - assert.equal(env.SERPER_API_KEY, "serper-secret"); - assert.equal(env.EXA_API_KEY, "exa-secret"); - assert.equal(env.CONTEXT7_API_KEY, "ctx7-secret"); - assert.equal(env.XIAOMI_API_KEY, "xiaomi-secret"); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); - - it("does not overwrite explicit environment variables", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-")); - const authPath = join(tempRoot, "auth.json"); - const env: NodeJS.ProcessEnv = { - BRAVE_API_KEY: "already-set", - }; - - try { - writeFileSync( - authPath, - JSON.stringify({ - brave: { type: "api_key", key: "from-auth-json" }, - anthropic: { type: "api_key", key: "sk-ant-from-auth-json" }, - }), - ); - - const loaded = loadStoredCredentialEnvKeys({ authPath, env }); - assert.deepEqual(loaded, ["ANTHROPIC_API_KEY"]); - assert.equal(env.BRAVE_API_KEY, "already-set"); - assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-from-auth-json"); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); - - it("ignores oauth credentials because they are resolved through auth storage, not env hydration", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-auth-")); - const authPath = join(tempRoot, "auth.json"); - const env: NodeJS.ProcessEnv = {}; - - try { - writeFileSync( - authPath, - JSON.stringify({ - openai: { type: "oauth", access: "oauth-access-token" }, - "google-gemini-cli": { type: "oauth", token: "ya29.oauth-token" }, - }), - ); - - const loaded = loadStoredCredentialEnvKeys({ authPath, env }); - assert.deepEqual(loaded, []); - assert.equal(env.OPENAI_API_KEY, undefined); - assert.equal(env.GEMINI_API_KEY, undefined); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); - - it("resolves auth.json from SF_CODING_AGENT_DIR", () => { - const tempRoot = mkdtempSync(join(tmpdir(), "sf-mcp-agent-dir-")); - const agentDir = join(tempRoot, "agent"); - mkdirSync(agentDir, { recursive: true }); - - try { - assert.equal( - resolveAuthPath({ SF_CODING_AGENT_DIR: agentDir }), - join(agentDir, "auth.json"), - ); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/mcp-server/src/tool-credentials.ts b/packages/mcp-server/src/tool-credentials.ts deleted file mode 100644 index 10fb0ccf2..000000000 --- a/packages/mcp-server/src/tool-credentials.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -type AuthCredential = - | { type?: unknown; key?: unknown } - | Array<{ type?: unknown; key?: unknown }>; - -type AuthStorageData = Record; - -const AUTH_ENV_KEYS = [ - ["anthropic", "ANTHROPIC_API_KEY"], - ["openai", "OPENAI_API_KEY"], - ["github-copilot", "GITHUB_TOKEN"], - ["google", "GEMINI_API_KEY"], - ["groq", "GROQ_API_KEY"], - ["xai", "XAI_API_KEY"], - ["openrouter", "OPENROUTER_API_KEY"], - ["mistral", "MISTRAL_API_KEY"], - ["xiaomi", "XIAOMI_API_KEY"], - ["xiaomi-token-plan-ams", "XIAOMI_API_KEY"], - ["xiaomi-token-plan-sgp", "XIAOMI_API_KEY"], - ["xiaomi-token-plan-cn", "XIAOMI_API_KEY"], - ["ollama-cloud", "OLLAMA_API_KEY"], - ["custom-openai", "CUSTOM_OPENAI_API_KEY"], - ["cerebras", "CEREBRAS_API_KEY"], - ["azure-openai-responses", "AZURE_OPENAI_API_KEY"], - ["vercel-ai-gateway", "AI_GATEWAY_API_KEY"], - ["zai", "ZAI_API_KEY"], - ["minimax", "MINIMAX_API_KEY"], - ["minimax-cn", "MINIMAX_CN_API_KEY"], - ["huggingface", "HF_TOKEN"], - ["opencode", "OPENCODE_API_KEY"], - ["opencode-go", "OPENCODE_API_KEY"], - ["kimi-coding", "KIMI_API_KEY"], - ["alibaba-coding-plan", "ALIBABA_API_KEY"], - ["brave", "BRAVE_API_KEY"], - ["brave_answers", "BRAVE_ANSWERS_KEY"], - ["serper", "SERPER_API_KEY"], - ["exa", "EXA_API_KEY"], - ["context7", "CONTEXT7_API_KEY"], - ["jina", "JINA_API_KEY"], - ["tavily", "TAVILY_API_KEY"], - ["slack_bot", "SLACK_BOT_TOKEN"], - ["discord_bot", "DISCORD_BOT_TOKEN"], - ["telegram_bot", "TELEGRAM_BOT_TOKEN"], -] as const; - -function expandHome(pathValue: string): string { - if (pathValue === "~") return homedir(); - if (pathValue.startsWith("~/")) return join(homedir(), pathValue.slice(2)); - return pathValue; -} - -function getStoredApiKey( - data: AuthStorageData, - providerId: string, -): string | undefined { - const raw = data[providerId]; - const credentials = Array.isArray(raw) ? raw : raw ? [raw] : []; - - for (const credential of credentials) { - if (credential?.type !== "api_key") continue; - if (typeof credential.key !== "string") continue; - if (credential.key.trim().length === 0) continue; - return credential.key; - } - - return undefined; -} - -export function resolveAuthPath(env: NodeJS.ProcessEnv = process.env): string { - const agentDir = env.SF_CODING_AGENT_DIR?.trim(); - if (agentDir) return join(expandHome(agentDir), "auth.json"); - return join(homedir(), ".sf", "agent", "auth.json"); -} - -export function loadStoredCredentialEnvKeys( - options: { env?: NodeJS.ProcessEnv; authPath?: string } = {}, -): string[] { - const env = options.env ?? process.env; - const authPath = options.authPath ?? resolveAuthPath(env); - if (!existsSync(authPath)) return []; - - let parsed: AuthStorageData; - try { - const raw = readFileSync(authPath, "utf-8"); - const data = JSON.parse(raw) as unknown; - if (!data || typeof data !== "object" || Array.isArray(data)) return []; - parsed = data as AuthStorageData; - } catch { - return []; - } - - const loaded: string[] = []; - for (const [providerId, envVar] of AUTH_ENV_KEYS) { - if (env[envVar]) continue; - const key = getStoredApiKey(parsed, providerId); - if (!key) continue; - env[envVar] = key; - loaded.push(envVar); - } - - return loaded; -} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts deleted file mode 100644 index aadbc2984..000000000 --- a/packages/mcp-server/src/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * MCP Server types — session lifecycle and orchestration. - */ - -import type { - RpcClient, - RpcExtensionUIRequest, - SdkAgentEvent, -} from "@singularity-forge/rpc-client"; - -// --------------------------------------------------------------------------- -// Session Status -// --------------------------------------------------------------------------- - -export type SessionStatus = - | "starting" - | "running" - | "blocked" - | "completed" - | "error" - | "cancelled"; - -// --------------------------------------------------------------------------- -// Managed Session -// --------------------------------------------------------------------------- - -export interface ManagedSession { - /** Unique session ID returned from RpcClient.init() */ - sessionId: string; - - /** Absolute path to the project directory */ - projectDir: string; - - /** Current lifecycle status */ - status: SessionStatus; - - /** The RpcClient instance managing the agent process */ - client: RpcClient; - - /** Ring buffer of recent events (capped at MAX_EVENTS) */ - events: SdkAgentEvent[]; - - /** Pending blocker requiring user response, if any */ - pendingBlocker: PendingBlocker | null; - - /** Cumulative cost tracking (max pattern per K004) */ - cost: CostAccumulator; - - /** Session start timestamp */ - startTime: number; - - /** Error message if status is 'error' */ - error?: string; - - /** Cleanup function to unsubscribe from events */ - unsubscribe?: () => void; -} - -// --------------------------------------------------------------------------- -// Pending Blocker -// --------------------------------------------------------------------------- - -export interface PendingBlocker { - /** The extension_ui_request id */ - id: string; - - /** The request method (e.g. 'select', 'confirm', 'input') */ - method: string; - - /** Human-readable message or title */ - message: string; - - /** Full event payload for inspection */ - event: RpcExtensionUIRequest; -} - -// --------------------------------------------------------------------------- -// Cost Accumulator (K004 — cumulative-max) -// --------------------------------------------------------------------------- - -export interface CostAccumulator { - totalCost: number; - tokens: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - }; -} - -// --------------------------------------------------------------------------- -// Execute Options -// --------------------------------------------------------------------------- - -export interface ExecuteOptions { - /** Command to send instead of the default '/sf autonomous' (default: none) */ - command?: string; - - /** Model ID override */ - model?: string; - - /** Run in bare mode (skip user config) */ - bare?: boolean; - - /** Path to CLI binary (overrides SF_CLI_PATH and which resolution) */ - cliPath?: string; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Maximum number of events kept in the ring buffer */ -export const MAX_EVENTS = 50; - -/** Timeout for RpcClient initialization (ms) */ -export const INIT_TIMEOUT_MS = 30_000; diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts deleted file mode 100644 index 5e512b75f..000000000 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ /dev/null @@ -1,1303 +0,0 @@ -import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, it } from "vitest"; - -import { - _getAdapter, - closeDatabase, -} from "../../../src/resources/extensions/sf/sf-db.ts"; -import { - _resetWorkflowModuleState, - registerWorkflowTools, - WORKFLOW_TOOL_NAMES, -} from "./workflow-tools.ts"; - -const REMOVED_WORKFLOW_TOOL_NAMES = [ - "sf_complete_task", - "sf_complete_slice", - "sf_generate_milestone_id", - "sf_save_decision", - "sf_update_requirement", - "sf_save_requirement", - "sf_save_summary", - "sf_milestone_plan", - "sf_slice_plan", - "sf_task_plan", - "sf_slice_replan", - "sf_roadmap_reassess", - "sf_milestone_complete", - "sf_milestone_validate", -] as const; - -function makeTmpBase(): string { - const base = join(tmpdir(), `sf-mcp-workflow-${randomUUID()}`); - mkdirSync(join(base, ".sf"), { recursive: true }); - return base; -} - -function cleanup(base: string): void { - try { - closeDatabase(); - } catch { - // swallow - } - try { - rmSync(base, { recursive: true, force: true }); - } catch { - // swallow - } -} - -function writeWriteGateSnapshot( - base: string, - snapshot: { - verifiedDepthMilestones?: string[]; - activeQueuePhase?: boolean; - pendingGateId?: string | null; - }, -): void { - mkdirSync(join(base, ".sf", "runtime"), { recursive: true }); - writeFileSync( - join(base, ".sf", "runtime", "write-gate-state.json"), - JSON.stringify( - { - verifiedDepthMilestones: snapshot.verifiedDepthMilestones ?? [], - activeQueuePhase: snapshot.activeQueuePhase ?? false, - pendingGateId: snapshot.pendingGateId ?? null, - }, - null, - 2, - ), - "utf-8", - ); -} - -function makeMockServer() { - const tools: Array<{ - name: string; - description: string; - params: Record; - handler: (args: Record) => Promise; - }> = []; - return { - tools, - tool( - name: string, - description: string, - params: Record, - handler: (args: Record) => Promise, - ) { - tools.push({ name, description, params, handler }); - }, - }; -} - -function validPlanningMeeting() { - return { - trigger: "MCP workflow test needs a recorded slice-planning decision.", - pm: "Keep this test slice narrow and focused on one workflow path.", - userAdvocate: "Users need the MCP path to preserve planning context.", - customerPanel: - "Operators and maintainers both need durable plan artifacts.", - business: "Reliable planning reduces wasted automation runs.", - researcher: "The MCP server delegates to the shared workflow executors.", - deliveryLead: "Use one small task to keep the integration proof bounded.", - partner: "The test covers the DB-backed render path.", - combatant: "Missing meetings would allow silent planning-context loss.", - architect: - "Schema and runtime validation should agree on the meeting contract.", - moderator: "Proceed with the focused planning proof.", - recommendedRoute: "planning", - confidenceSummary: "High confidence for this test fixture.", - }; -} - -describe("workflow MCP tools", () => { - it("registers the full headless-safe workflow tool surface", () => { - const server = makeMockServer(); - registerWorkflowTools(server as any); - - assert.equal(server.tools.length, WORKFLOW_TOOL_NAMES.length); - assert.deepEqual( - server.tools.map((t) => t.name), - [...WORKFLOW_TOOL_NAMES], - ); - for (const removedName of REMOVED_WORKFLOW_TOOL_NAMES) { - assert.ok( - !server.tools.some((t) => t.name === removedName), - `${removedName} should not be registered`, - ); - } - }); - - it("sf_summary_save writes artifact through the shared executor", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const tool = server.tools.find((t) => t.name === "sf_summary_save"); - assert.ok(tool, "summary tool should be registered"); - const originalCwd = process.cwd(); - - const result = await tool!.handler({ - projectDir: base, - milestone_id: "M001", - slice_id: "S01", - artifact_type: "SUMMARY", - content: "# Summary\n\nHello", - }); - - const text = (result as any).content[0].text as string; - assert.match(text, /Saved SUMMARY artifact/); - assert.equal( - process.cwd(), - originalCwd, - "workflow MCP tools should not mutate process.cwd", - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M001", - "slices", - "S01", - "S01-SUMMARY.md", - ), - ), - "summary file should exist on disk", - ); - } finally { - cleanup(base); - } - }); - - it("rejects workflow tool calls outside the configured project root", async () => { - const base = makeTmpBase(); - const otherBase = makeTmpBase(); - const prevRoot = process.env.SF_WORKFLOW_PROJECT_ROOT; - try { - process.env.SF_WORKFLOW_PROJECT_ROOT = base; - const server = makeMockServer(); - registerWorkflowTools(server as any); - const tool = server.tools.find((t) => t.name === "sf_summary_save"); - assert.ok(tool, "summary tool should be registered"); - - await assert.rejects( - () => - tool!.handler({ - projectDir: otherBase, - milestone_id: "M001", - artifact_type: "SUMMARY", - content: "# Summary", - }), - /configured workflow project root/, - ); - } finally { - if (prevRoot === undefined) { - delete process.env.SF_WORKFLOW_PROJECT_ROOT; - } else { - process.env.SF_WORKFLOW_PROJECT_ROOT = prevRoot; - } - cleanup(base); - cleanup(otherBase); - } - }); - - it("rejects non-file executor module URLs", async () => { - const base = makeTmpBase(); - const prevModule = process.env.SF_WORKFLOW_EXECUTORS_MODULE; - const prevRoot = process.env.SF_WORKFLOW_PROJECT_ROOT; - try { - process.env.SF_WORKFLOW_PROJECT_ROOT = base; - process.env.SF_WORKFLOW_EXECUTORS_MODULE = - "data:text/javascript,export default {}"; - _resetWorkflowModuleState(); - const server = makeMockServer(); - registerWorkflowTools(server as any); - const tool = server.tools.find((t) => t.name === "sf_summary_save"); - assert.ok(tool, "summary tool should be registered"); - - await assert.rejects( - () => - tool!.handler({ - projectDir: base, - milestone_id: "M001", - artifact_type: "SUMMARY", - content: "# Summary", - }), - /only supports file: URLs or filesystem paths/, - ); - } finally { - if (prevModule === undefined) { - delete process.env.SF_WORKFLOW_EXECUTORS_MODULE; - } else { - process.env.SF_WORKFLOW_EXECUTORS_MODULE = prevModule; - } - if (prevRoot === undefined) { - delete process.env.SF_WORKFLOW_PROJECT_ROOT; - } else { - process.env.SF_WORKFLOW_PROJECT_ROOT = prevRoot; - } - _resetWorkflowModuleState(); - cleanup(base); - } - }); - - it("blocks workflow mutation tools while a discussion gate is pending", async () => { - const base = makeTmpBase(); - try { - mkdirSync(join(base, ".sf", "milestones", "M001", "slices", "S01"), { - recursive: true, - }); - writeFileSync( - join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), - "# S01\n\n- [ ] **T01: Demo** `est:5m`\n", - ); - writeWriteGateSnapshot(base, { - pendingGateId: "depth_verification_M001_confirm", - }); - - const server = makeMockServer(); - registerWorkflowTools(server as any); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - assert.ok(taskTool, "task tool should be registered"); - - await assert.rejects( - () => - taskTool!.handler({ - projectDir: base, - taskId: "T01", - sliceId: "S01", - milestoneId: "M001", - oneLiner: "Completed task", - narrative: "Did the work", - verification: "npm test", - }), - /Discussion gate .* has not been confirmed/, - ); - } finally { - cleanup(base); - } - }); - - it("blocks workflow mutation tools during queue mode", async () => { - const base = makeTmpBase(); - try { - mkdirSync(join(base, ".sf", "milestones", "M001", "slices", "S01"), { - recursive: true, - }); - writeFileSync( - join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), - "# S01\n\n- [ ] **T01: Demo** `est:5m`\n", - ); - writeWriteGateSnapshot(base, { activeQueuePhase: true }); - - const server = makeMockServer(); - registerWorkflowTools(server as any); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - assert.ok(taskTool, "task tool should be registered"); - - await assert.rejects( - () => - taskTool!.handler({ - projectDir: base, - taskId: "T01", - sliceId: "S01", - milestoneId: "M001", - oneLiner: "Completed task", - narrative: "Did the work", - verification: "npm test", - }), - /planning tool .* not executes work|Cannot sf_task_complete|Unknown tools are not permitted during queue mode/, - ); - } finally { - cleanup(base); - } - }); - - it("sf_task_complete and sf_milestone_status work end-to-end", async () => { - const base = makeTmpBase(); - try { - mkdirSync(join(base, ".sf", "milestones", "M001", "slices", "S01"), { - recursive: true, - }); - writeFileSync( - join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-PLAN.md"), - "# S01\n\n- [ ] **T01: Demo** `est:5m`\n", - ); - - const server = makeMockServer(); - registerWorkflowTools(server as any); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - const statusTool = server.tools.find( - (t) => t.name === "sf_milestone_status", - ); - assert.ok(taskTool, "task tool should be registered"); - assert.ok(statusTool, "status tool should be registered"); - - const taskResult = await taskTool!.handler({ - projectDir: base, - taskId: "T01", - sliceId: "S01", - milestoneId: "M001", - oneLiner: "Completed task", - narrative: "Did the work", - verification: "npm test", - }); - - assert.match( - (taskResult as any).content[0].text as string, - /Completed task T01/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M001", - "slices", - "S01", - "tasks", - "T01-SUMMARY.md", - ), - ), - "task summary should be written to disk", - ); - - const statusResult = await statusTool!.handler({ - projectDir: base, - milestoneId: "M001", - }); - const parsed = JSON.parse( - (statusResult as any).content[0].text as string, - ); - assert.equal(parsed.milestoneId, "M001"); - assert.equal(parsed.sliceCount, 1); - assert.equal(parsed.slices[0].id, "S01"); - } finally { - cleanup(base); - } - }); - - it("sf_plan_milestone and sf_plan_slice work end-to-end", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - - const milestoneResult = await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M001", - title: "Workflow MCP planning", - vision: "Plan milestone over MCP.", - slices: [ - { - sliceId: "S01", - title: "Bridge planning", - risk: "medium", - depends: [], - demo: "Milestone plan persists through MCP.", - goal: "Persist roadmap state.", - successCriteria: "ROADMAP.md renders from DB.", - proofLevel: "integration", - integrationClosure: "Prompts and MCP call the same handler.", - observabilityImpact: "Executor tests cover output paths.", - }, - ], - }); - assert.match( - (milestoneResult as any).content[0].text as string, - /Planned milestone M001/, - ); - - const sliceResult = await sliceTool!.handler({ - projectDir: base, - milestoneId: "M001", - sliceId: "S01", - goal: "Persist slice plan over MCP.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T01", - title: "Add planning bridge", - description: "Implement the shared executor path.", - estimate: "15m", - files: [ - "src/resources/extensions/sf/tools/workflow-tool-executors.ts", - ], - verify: "node --test", - inputs: ["ROADMAP.md"], - expectedOutput: ["S01-PLAN.md", "T01-PLAN.md"], - }, - ], - }); - assert.match( - (sliceResult as any).content[0].text as string, - /Planned slice S01/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M001", - "slices", - "S01", - "S01-PLAN.md", - ), - ), - "slice plan should exist on disk", - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M001", - "slices", - "S01", - "tasks", - "T01-PLAN.md", - ), - ), - "task plan should exist on disk", - ); - } finally { - cleanup(base); - } - }); - - it("sf_requirement_save opens the DB before inline requirement writes", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const requirementTool = server.tools.find( - (t) => t.name === "sf_requirement_save", - ); - assert.ok(requirementTool, "requirement tool should be registered"); - - closeDatabase(); - - const result = await requirementTool!.handler({ - projectDir: base, - class: "operability", - description: "Inline MCP requirement save regression", - why: "Reproduce missing ensureDbOpen in workflow-tools", - source: "user", - status: "active", - primary_owner: "M010/S10", - validation: "n/a", - }); - - assert.match( - (result as any).content[0].text as string, - /Saved requirement R\d+/, - ); - assert.ok( - existsSync(join(base, ".sf", "REQUIREMENTS.md")), - "REQUIREMENTS.md should be written to disk", - ); - const row = _getAdapter()! - .prepare( - "SELECT id, class, description FROM requirements WHERE description = ?", - ) - .get("Inline MCP requirement save regression") as - | Record - | undefined; - assert.ok(row, "requirement should be written to the database"); - assert.equal(row["class"], "operability"); - } finally { - cleanup(base); - } - }); - - it("sf_plan_task reopens the DB before inline task planning writes", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - const taskTool = server.tools.find((t) => t.name === "sf_plan_task"); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - assert.ok(taskTool, "task planning tool should be registered"); - - await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M010", - title: "Inline task planning DB reopen", - vision: "Seed a slice, close the DB, then plan another task inline.", - slices: [ - { - sliceId: "S10", - title: "Inline task planning", - risk: "medium", - depends: [], - demo: "Inline sf_plan_task reopens the DB after it was closed.", - goal: "Preserve MCP task planning after the DB adapter is closed.", - successCriteria: - "The second task plan persists after a closed DB is reopened.", - proofLevel: "integration", - integrationClosure: - "The inline MCP handler reopens the DB before planning.", - observabilityImpact: - "workflow-tools MCP tests cover the inline reopen path.", - }, - ], - }); - await sliceTool!.handler({ - projectDir: base, - milestoneId: "M010", - sliceId: "S10", - goal: "Create the initial slice plan before closing the DB.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T10", - title: "Seed existing task", - description: "Create the initial task plan before closing the DB.", - estimate: "5m", - files: ["packages/mcp-server/src/workflow-tools.ts"], - verify: "node --test", - inputs: ["M010-ROADMAP.md"], - expectedOutput: ["T10-PLAN.md"], - }, - ], - }); - - closeDatabase(); - - const result = await taskTool!.handler({ - projectDir: base, - milestoneId: "M010", - sliceId: "S10", - taskId: "T11", - title: "Reopen and plan", - description: - "Exercise the inline plan-task path after the DB was closed.", - estimate: "5m", - files: ["packages/mcp-server/src/workflow-tools.ts"], - verify: "node --test", - inputs: ["M010-ROADMAP.md", "S10-PLAN.md"], - expectedOutput: ["T11-PLAN.md"], - }); - - assert.match( - (result as any).content[0].text as string, - /Planned task T11/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M010", - "slices", - "S10", - "tasks", - "T11-PLAN.md", - ), - ), - "T11 plan should be written after reopening the DB", - ); - } finally { - cleanup(base); - } - }); - - it("sf_replan_slice works end-to-end", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - const replanTool = server.tools.find((t) => t.name === "sf_replan_slice"); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - assert.ok(taskTool, "task completion tool should be registered"); - assert.ok(replanTool, "slice replanning tool should be registered"); - - await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M099", - title: "Slice replanning", - vision: "Drive replan parity over MCP.", - slices: [ - { - sliceId: "S09", - title: "Replan slice", - risk: "medium", - depends: [], - demo: "Slice replans after a blocker task completes.", - goal: "Prepare replan state.", - successCriteria: "Plan and replan artifacts update over MCP.", - proofLevel: "integration", - integrationClosure: "Replan uses the shared executor path.", - observabilityImpact: "Tests cover replan artifacts.", - }, - ], - }); - await sliceTool!.handler({ - projectDir: base, - milestoneId: "M099", - sliceId: "S09", - goal: "Plan a slice that will be replanned.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T09", - title: "Blocker task", - description: "Finish the blocker-discovery task.", - estimate: "5m", - files: ["src/blocker.ts"], - verify: "node --test", - inputs: ["M099-ROADMAP.md"], - expectedOutput: ["T09-SUMMARY.md"], - }, - { - taskId: "T10", - title: "Pending task", - description: "Original follow-up task.", - estimate: "10m", - files: ["src/pending.ts"], - verify: "node --test", - inputs: ["S09-PLAN.md"], - expectedOutput: ["Updated plan"], - }, - ], - }); - await taskTool!.handler({ - projectDir: base, - milestoneId: "M099", - sliceId: "S09", - taskId: "T09", - oneLiner: "Completed blocker task", - narrative: "Prepared the slice for replanning.", - verification: "node --test", - }); - - const firstReplanResult = await replanTool!.handler({ - projectDir: base, - milestoneId: "M099", - sliceId: "S09", - blockerTaskId: "T09", - blockerDescription: "Original approach is no longer viable.", - whatChanged: "Updated the remaining task and added remediation work.", - updatedTasks: [ - { - taskId: "T10", - title: "Pending task (updated)", - description: "Updated follow-up task after replanning.", - estimate: "15m", - files: ["src/pending.ts", "src/replanned.ts"], - verify: "node --test", - inputs: ["S09-PLAN.md"], - expectedOutput: ["Updated plan"], - }, - { - taskId: "T11", - title: "Remediation task", - description: "New task introduced by the replan.", - estimate: "20m", - files: ["src/remediation.ts"], - verify: "node --test", - inputs: ["S09-REPLAN.md"], - expectedOutput: ["Remediation patch"], - }, - ], - removedTaskIds: [], - }); - assert.match( - (firstReplanResult as any).content[0].text as string, - /Replanned slice S09/, - ); - - const secondReplanResult = await replanTool!.handler({ - projectDir: base, - milestoneId: "M099", - sliceId: "S09", - blockerTaskId: "T09", - blockerDescription: "Follow-up replan confirms the canonical flow.", - whatChanged: "Removed the remediation task after the follow-up replan.", - updatedTasks: [ - { - taskId: "T10", - title: "Pending task (updated again)", - description: - "Follow-up replan adjusted the remaining pending task.", - estimate: "12m", - files: ["src/pending.ts"], - verify: "node --test", - inputs: ["S09-PLAN.md"], - expectedOutput: ["Updated plan"], - }, - ], - removedTaskIds: ["T11"], - }); - assert.match( - (secondReplanResult as any).content[0].text as string, - /Replanned slice S09/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M099", - "slices", - "S09", - "S09-REPLAN.md", - ), - ), - "replan artifact should exist on disk", - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M099", - "slices", - "S09", - "S09-PLAN.md", - ), - ), - "updated plan should exist on disk", - ); - const removedTask = _getAdapter()! - .prepare( - "SELECT id FROM tasks WHERE milestone_id = ? AND slice_id = ? AND id = ?", - ) - .get("M099", "S09", "T11"); - assert.equal( - removedTask, - undefined, - "follow-up replan should remove the replanned task", - ); - } finally { - cleanup(base); - } - }); - - it("sf_slice_complete works end-to-end", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - const canonicalTool = server.tools.find( - (t) => t.name === "sf_slice_complete", - ); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - assert.ok(taskTool, "task completion tool should be registered"); - assert.ok(canonicalTool, "slice completion tool should be registered"); - - await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M003", - title: "Demo milestone", - vision: "Prepare canonical slice completion state.", - slices: [ - { - sliceId: "S03", - title: "Demo Slice", - risk: "medium", - depends: [], - demo: "Canonical slice completes through MCP.", - goal: "Seed workflow state.", - successCriteria: "Slice summary and UAT files are written.", - proofLevel: "integration", - integrationClosure: "Planning and completion share the MCP bridge.", - observabilityImpact: "Workflow tests cover canonical completion.", - }, - ], - }); - await sliceTool!.handler({ - projectDir: base, - milestoneId: "M003", - sliceId: "S03", - goal: "Complete canonical slice over MCP.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T03", - title: "Canonical task", - description: "Seed a completed task for slice completion.", - estimate: "5m", - files: ["packages/mcp-server/src/workflow-tools.ts"], - verify: "node --test", - inputs: ["M003-ROADMAP.md"], - expectedOutput: ["S03-SUMMARY.md", "S03-UAT.md"], - }, - ], - }); - await taskTool!.handler({ - projectDir: base, - milestoneId: "M003", - sliceId: "S03", - taskId: "T03", - oneLiner: "Completed canonical task", - narrative: "Prepared the canonical slice for completion.", - verification: "node --test", - }); - - const canonicalResult = await canonicalTool!.handler({ - projectDir: base, - milestoneId: "M003", - sliceId: "S03", - sliceTitle: "Demo Slice", - oneLiner: "Completed canonical slice", - narrative: "Did the slice work", - verification: "npm test", - uatContent: "## UAT\n\nPASS", - }); - assert.match( - (canonicalResult as any).content[0].text as string, - /Completed slice S03/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M003", - "slices", - "S03", - "S03-SUMMARY.md", - ), - ), - "canonical tool should write slice summary to disk", - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M003", - "slices", - "S03", - "S03-UAT.md", - ), - ), - "canonical tool should write slice UAT to disk", - ); - } finally { - cleanup(base); - } - }); - - it("sf_validate_milestone and sf_complete_milestone work end-to-end", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - const completeSliceTool = server.tools.find( - (t) => t.name === "sf_slice_complete", - ); - const validateTool = server.tools.find( - (t) => t.name === "sf_validate_milestone", - ); - const completeMilestoneTool = server.tools.find( - (t) => t.name === "sf_complete_milestone", - ); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - assert.ok(taskTool, "task completion tool should be registered"); - assert.ok( - completeSliceTool, - "slice completion tool should be registered", - ); - assert.ok(validateTool, "milestone validation tool should be registered"); - assert.ok( - completeMilestoneTool, - "milestone completion tool should be registered", - ); - - await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M005", - title: "Milestone lifecycle", - vision: "Drive validation and completion over MCP.", - slices: [ - { - sliceId: "S05", - title: "Lifecycle slice", - risk: "medium", - depends: [], - demo: "Milestone can validate and complete.", - goal: "Seed milestone completion state.", - successCriteria: "Summary and validation artifacts are written.", - proofLevel: "integration", - integrationClosure: "Lifecycle tools share the MCP bridge.", - observabilityImpact: "Tests cover milestone end-to-end behavior.", - }, - ], - }); - await sliceTool!.handler({ - projectDir: base, - milestoneId: "M005", - sliceId: "S05", - goal: "Prepare a complete milestone.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T05", - title: "Lifecycle task", - description: "Seed a fully completed slice.", - estimate: "10m", - files: ["packages/mcp-server/src/workflow-tools.ts"], - verify: "node --test", - inputs: ["M005-ROADMAP.md"], - expectedOutput: ["M005-VALIDATION.md", "M005-SUMMARY.md"], - }, - ], - }); - await taskTool!.handler({ - projectDir: base, - milestoneId: "M005", - sliceId: "S05", - taskId: "T05", - oneLiner: "Completed lifecycle task", - narrative: "Prepared the milestone for closure.", - verification: "node --test", - }); - await completeSliceTool!.handler({ - projectDir: base, - milestoneId: "M005", - sliceId: "S05", - sliceTitle: "Lifecycle Slice", - oneLiner: "Completed lifecycle slice", - narrative: "Closed the milestone slice.", - verification: "node --test", - uatContent: "## UAT\n\nPASS", - }); - - const validationResult = await validateTool!.handler({ - projectDir: base, - milestoneId: "M005", - verdict: "pass", - remediationRound: 0, - successCriteriaChecklist: "- [x] Lifecycle verified", - sliceDeliveryAudit: - "| Slice | Verdict |\n| --- | --- |\n| S05 | pass |", - crossSliceIntegration: "No cross-slice mismatches found.", - requirementCoverage: "No requirement gaps remain.", - verdictRationale: "The milestone delivered its scope.", - }); - assert.match( - (validationResult as any).content[0].text as string, - /Validated milestone M005/, - ); - - const completionResult = await completeMilestoneTool!.handler({ - projectDir: base, - milestoneId: "M005", - title: "Milestone lifecycle", - oneLiner: "Milestone closed successfully", - narrative: "Validation passed and all slices were complete.", - verificationPassed: true, - }); - assert.match( - (completionResult as any).content[0].text as string, - /Completed milestone M005/, - ); - assert.ok( - existsSync( - join(base, ".sf", "milestones", "M005", "M005-VALIDATION.md"), - ), - "validation artifact should exist on disk", - ); - assert.ok( - existsSync(join(base, ".sf", "milestones", "M005", "M005-SUMMARY.md")), - "milestone summary should exist on disk", - ); - } finally { - cleanup(base); - } - }); - - it("sf_reassess_roadmap and sf_save_gate_result work end-to-end", async () => { - const base = makeTmpBase(); - try { - const server = makeMockServer(); - registerWorkflowTools(server as any); - const milestoneTool = server.tools.find( - (t) => t.name === "sf_plan_milestone", - ); - const sliceTool = server.tools.find((t) => t.name === "sf_plan_slice"); - const taskTool = server.tools.find((t) => t.name === "sf_task_complete"); - const completeSliceTool = server.tools.find( - (t) => t.name === "sf_slice_complete", - ); - const reassessTool = server.tools.find( - (t) => t.name === "sf_reassess_roadmap", - ); - const gateTool = server.tools.find( - (t) => t.name === "sf_save_gate_result", - ); - assert.ok(milestoneTool, "milestone planning tool should be registered"); - assert.ok(sliceTool, "slice planning tool should be registered"); - assert.ok(taskTool, "task completion tool should be registered"); - assert.ok( - completeSliceTool, - "slice completion tool should be registered", - ); - assert.ok(reassessTool, "roadmap reassessment tool should be registered"); - assert.ok(gateTool, "gate result tool should be registered"); - - await milestoneTool!.handler({ - projectDir: base, - milestoneId: "M006", - title: "Roadmap reassessment", - vision: "Drive gate results and reassessment over MCP.", - slices: [ - { - sliceId: "S06", - title: "Completed slice", - risk: "medium", - depends: [], - demo: "Completed slice triggers reassessment.", - goal: "Seed reassessment state.", - successCriteria: "Assessment and roadmap artifacts are written.", - proofLevel: "integration", - integrationClosure: "Roadmap updates share the MCP bridge.", - observabilityImpact: "Tests cover reassessment behavior.", - }, - { - sliceId: "S07", - title: "Follow-up slice", - risk: "low", - depends: ["S06"], - demo: "Follow-up slice remains pending.", - goal: "Leave room for roadmap edits.", - successCriteria: "Roadmap mutation succeeds.", - proofLevel: "integration", - integrationClosure: - "Pending slice can be modified after reassessment.", - observabilityImpact: "Tests observe roadmap mutation output.", - }, - ], - }); - await sliceTool!.handler({ - projectDir: base, - milestoneId: "M006", - sliceId: "S06", - goal: "Complete the first slice.", - planningMeeting: validPlanningMeeting(), - tasks: [ - { - taskId: "T06", - title: "Seed completed slice", - description: "Prepare gate and reassessment state.", - estimate: "10m", - files: ["packages/mcp-server/src/workflow-tools.ts"], - verify: "node --test", - inputs: ["M006-ROADMAP.md"], - expectedOutput: ["S06-ASSESSMENT.md", "M006-ROADMAP.md"], - }, - ], - }); - - const gateResult = await gateTool!.handler({ - projectDir: base, - milestoneId: "M006", - sliceId: "S06", - gateId: "Q3", - verdict: "pass", - rationale: "Threat surface is covered.", - findings: "No new attack surface was introduced.", - }); - assert.match( - (gateResult as any).content[0].text as string, - /Gate Q3 result saved/, - ); - const gateRows = _getAdapter()! - .prepare( - "SELECT status, verdict, rationale FROM quality_gates WHERE milestone_id = ? AND slice_id = ? AND gate_id = ?", - ) - .all("M006", "S06", "Q3") as Array>; - assert.equal(gateRows.length, 1); - assert.equal(gateRows[0]["status"], "complete"); - assert.equal(gateRows[0]["verdict"], "pass"); - - await taskTool!.handler({ - projectDir: base, - milestoneId: "M006", - sliceId: "S06", - taskId: "T06", - oneLiner: "Completed reassessment task", - narrative: "Prepared the slice for reassessment.", - verification: "node --test", - }); - await completeSliceTool!.handler({ - projectDir: base, - milestoneId: "M006", - sliceId: "S06", - sliceTitle: "Completed slice", - oneLiner: "Completed reassessment slice", - narrative: "Closed the completed slice before reassessment.", - verification: "node --test", - uatContent: "## UAT\n\nPASS", - }); - - const reassessResult = await reassessTool!.handler({ - projectDir: base, - milestoneId: "M006", - completedSliceId: "S06", - verdict: "roadmap-adjusted", - assessment: "Insert remediation work after the completed slice.", - sliceChanges: { - modified: [ - { - sliceId: "S07", - title: "Follow-up slice (adjusted)", - risk: "medium", - depends: ["S06"], - demo: "Adjusted demo", - }, - ], - added: [ - { - sliceId: "S08", - title: "Remediation slice", - risk: "high", - depends: ["S07"], - demo: "Remediation demo", - }, - ], - removed: [], - }, - }); - assert.match( - (reassessResult as any).content[0].text as string, - /Reassessed roadmap for milestone M006 after S06/, - ); - - const secondReassessResult = await reassessTool!.handler({ - projectDir: base, - milestoneId: "M006", - completedSliceId: "S06", - verdict: "roadmap-confirmed", - assessment: "No further changes needed after the first reassessment.", - sliceChanges: { - modified: [], - added: [], - removed: [], - }, - }); - assert.match( - (secondReassessResult as any).content[0].text as string, - /Reassessed roadmap for milestone M006 after S06/, - ); - assert.ok( - existsSync( - join( - base, - ".sf", - "milestones", - "M006", - "slices", - "S06", - "S06-ASSESSMENT.md", - ), - ), - "assessment artifact should exist on disk", - ); - assert.ok( - existsSync(join(base, ".sf", "milestones", "M006", "M006-ROADMAP.md")), - "roadmap artifact should exist on disk", - ); - } finally { - cleanup(base); - } - }); -}); - -describe("URL scheme regex — Windows drive letter safety", () => { - // This is the regex used in getWriteGateModuleCandidates() and - // getWorkflowExecutorModuleCandidates() to reject non-file URL schemes. - // It must NOT match single-letter Windows drive prefixes (C:, D:, etc.). - const urlSchemeRegex = /^[a-z]{2,}:/i; - - it("rejects multi-letter URL schemes", () => { - assert.ok(urlSchemeRegex.test("http://example.com"), "http: should match"); - assert.ok( - urlSchemeRegex.test("https://example.com"), - "https: should match", - ); - assert.ok( - urlSchemeRegex.test("ftp://files.example.com"), - "ftp: should match", - ); - assert.ok(urlSchemeRegex.test("file:///C:/Users"), "file: should match"); - assert.ok(urlSchemeRegex.test("node:fs"), "node: should match"); - }); - - it("allows single-letter Windows drive prefixes", () => { - assert.ok( - !urlSchemeRegex.test("C:\\Users\\user\\project"), - "C:\\ should not match", - ); - assert.ok(!urlSchemeRegex.test("D:\\other\\path"), "D:\\ should not match"); - assert.ok( - !urlSchemeRegex.test("c:\\lowercase\\drive"), - "c:\\ should not match", - ); - assert.ok( - !urlSchemeRegex.test("E:/forward/slash/path"), - "E:/ should not match", - ); - }); - - it("allows bare filesystem paths", () => { - assert.ok( - !urlSchemeRegex.test("/usr/local/lib/module.js"), - "unix absolute path should not match", - ); - assert.ok( - !urlSchemeRegex.test("./relative/path.js"), - "relative path should not match", - ); - assert.ok( - !urlSchemeRegex.test("../parent/path.js"), - "parent relative path should not match", - ); - }); -}); diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts deleted file mode 100644 index 6aefdec30..000000000 --- a/packages/mcp-server/src/workflow-tools.ts +++ /dev/null @@ -1,1711 +0,0 @@ -/** - * Workflow MCP tools — exposes the core SF mutation/read handlers over MCP. - */ - -import { isAbsolute, relative, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { z } from "zod"; - -type WorkflowToolExecutors = { - SUPPORTED_SUMMARY_ARTIFACT_TYPES: readonly string[]; - executeMilestoneStatus: ( - params: { milestoneId: string }, - basePath?: string, - ) => Promise; - executePlanMilestone: ( - params: { - milestoneId: string; - title: string; - vision: string; - slices?: Array<{ - sliceId: string; - title: string; - risk: string; - depends: string[]; - demo: string; - goal: string; - successCriteria: string; - proofLevel: string; - integrationClosure: string; - observabilityImpact: string; - }>; - templateId?: string; - status?: string; - dependsOn?: string[]; - successCriteria?: string[]; - keyRisks?: Array<{ risk: string; whyItMatters: string }>; - proofStrategy?: Array<{ - riskOrUnknown: string; - retireIn: string; - whatWillBeProven: string; - }>; - verificationContract?: string; - verificationIntegration?: string; - verificationOperational?: string; - verificationUat?: string; - definitionOfDone?: string[]; - requirementCoverage?: string; - boundaryMapMarkdown?: string; - visionMeeting?: { - trigger: string; - pm: string; - userAdvocate: string; - customerPanel: string; - business: string; - researcher: string; - deliveryLead: string; - partner: string; - combatant: string; - architect: string; - moderator: string; - weightedSynthesis: string; - confidenceByArea: string; - recommendedRoute: "discussing" | "researching" | "planning"; - }; - }, - basePath?: string, - ) => Promise; - executePlanSlice: ( - params: { - milestoneId: string; - sliceId: string; - goal: string; - tasks: Array<{ - taskId: string; - title: string; - description: string; - estimate: string; - files: string[]; - verify: string; - inputs: string[]; - expectedOutput: string[]; - observabilityImpact?: string; - }>; - successCriteria?: string; - proofLevel?: string; - integrationClosure?: string; - observabilityImpact?: string; - }, - basePath?: string, - ) => Promise; - executeReplanSlice: ( - params: { - milestoneId: string; - sliceId: string; - blockerTaskId: string; - blockerDescription: string; - whatChanged: string; - updatedTasks: Array<{ - taskId: string; - title: string; - description: string; - estimate: string; - files: string[]; - verify: string; - inputs: string[]; - expectedOutput: string[]; - fullPlanMd?: string; - }>; - removedTaskIds: string[]; - }, - basePath?: string, - ) => Promise; - executeSliceComplete: ( - params: { - sliceId: string; - milestoneId: string; - sliceTitle: string; - oneLiner: string; - narrative: string; - verification: string; - uatContent: string; - deviations?: string; - knownLimitations?: string; - followUps?: string; - keyFiles?: string[] | string; - keyDecisions?: string[] | string; - patternsEstablished?: string[] | string; - observabilitySurfaces?: string[] | string; - provides?: string[] | string; - requirementsSurfaced?: string[] | string; - drillDownPaths?: string[] | string; - affects?: string[] | string; - requirementsAdvanced?: Array<{ id: string; how: string } | string>; - requirementsValidated?: Array<{ id: string; proof: string } | string>; - requirementsInvalidated?: Array<{ id: string; what: string } | string>; - filesModified?: Array<{ path: string; description: string } | string>; - requires?: Array<{ slice: string; provides: string } | string>; - }, - basePath?: string, - ) => Promise; - executeCompleteMilestone: ( - params: { - milestoneId: string; - title: string; - oneLiner: string; - narrative: string; - verificationPassed: boolean; - successCriteriaResults?: string; - definitionOfDoneResults?: string; - requirementOutcomes?: string; - keyDecisions?: string[]; - keyFiles?: string[]; - lessonsLearned?: string[]; - followUps?: string; - deviations?: string; - }, - basePath?: string, - ) => Promise; - executeValidateMilestone: ( - params: { - milestoneId: string; - verdict: "pass" | "needs-attention" | "needs-remediation"; - remediationRound: number; - successCriteriaChecklist: string; - sliceDeliveryAudit: string; - crossSliceIntegration: string; - requirementCoverage: string; - verificationClasses?: string; - verdictRationale: string; - remediationPlan?: string; - }, - basePath?: string, - ) => Promise; - executeReassessRoadmap: ( - params: { - milestoneId: string; - completedSliceId: string; - verdict: string; - assessment: string; - sliceChanges: { - modified: Array<{ - sliceId: string; - title: string; - risk?: string; - depends?: string[]; - demo?: string; - }>; - added: Array<{ - sliceId: string; - title: string; - risk?: string; - depends?: string[]; - demo?: string; - }>; - removed: string[]; - }; - }, - basePath?: string, - ) => Promise; - executeSaveGateResult: ( - params: { - milestoneId: string; - sliceId: string; - gateId: string; - taskId?: string; - verdict: "pass" | "flag" | "omitted"; - rationale: string; - findings?: string; - }, - basePath?: string, - ) => Promise; - executeSummarySave: ( - params: { - milestone_id: string; - slice_id?: string; - task_id?: string; - artifact_type: string; - content: string; - }, - basePath?: string, - ) => Promise; - executeTaskComplete: ( - params: { - taskId: string; - sliceId: string; - milestoneId: string; - oneLiner: string; - narrative: string; - verification: string; - deviations?: string; - knownIssues?: string; - keyFiles?: string[]; - keyDecisions?: string[]; - blockerDiscovered?: boolean; - verificationEvidence?: Array< - | { - command: string; - exitCode: number; - verdict: string; - durationMs: number; - } - | string - >; - }, - basePath?: string, - ) => Promise; -}; - -type WorkflowWriteGateModule = { - loadWriteGateSnapshot: (basePath?: string) => { - verifiedDepthMilestones: string[]; - activeQueuePhase: boolean; - pendingGateId: string | null; - }; - shouldBlockPendingGateInSnapshot: ( - snapshot: { - verifiedDepthMilestones: string[]; - activeQueuePhase: boolean; - pendingGateId: string | null; - }, - toolName: string, - milestoneId: string | null, - queuePhaseActive?: boolean, - ) => { block: boolean; reason?: string }; - shouldBlockQueueExecutionInSnapshot: ( - snapshot: { - verifiedDepthMilestones: string[]; - activeQueuePhase: boolean; - pendingGateId: string | null; - }, - toolName: string, - input: string, - queuePhaseActive?: boolean, - ) => { block: boolean; reason?: string }; -}; - -type WorkflowDbBootstrapModule = { - ensureDbOpen: (basePath?: string) => Promise; -}; - -let workflowToolExecutorsPromise: Promise | null = null; -let workflowExecutionQueue: Promise = Promise.resolve(); -let workflowWriteGatePromise: Promise | null = null; - -/** Reset module-level singletons so tests can vary env vars between runs. */ -export function _resetWorkflowModuleState(): void { - workflowToolExecutorsPromise = null; - workflowExecutionQueue = Promise.resolve(); - workflowWriteGatePromise = null; -} - -function getAllowedProjectRoot( - env: NodeJS.ProcessEnv = process.env, -): string | null { - const configuredRoot = env.SF_WORKFLOW_PROJECT_ROOT?.trim(); - return configuredRoot ? resolve(configuredRoot) : null; -} - -function isWithinRoot(candidatePath: string, rootPath: string): boolean { - const rel = relative(rootPath, candidatePath); - return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); -} - -function validateProjectDir( - projectDir: string, - env: NodeJS.ProcessEnv = process.env, -): string { - if (!isAbsolute(projectDir)) { - throw new Error( - `projectDir must be an absolute path. Received: ${projectDir}`, - ); - } - - const resolvedProjectDir = resolve(projectDir); - const allowedRoot = getAllowedProjectRoot(env); - if (allowedRoot && !isWithinRoot(resolvedProjectDir, allowedRoot)) { - throw new Error( - `projectDir must stay within the configured workflow project root. Received: ${resolvedProjectDir}; allowed root: ${allowedRoot}`, - ); - } - - return resolvedProjectDir; -} - -function parseToolArgs( - schema: z.ZodType, - args: Record, -): T { - return schema.parse(args); -} - -function parseWorkflowArgs( - schema: z.ZodType, - args: Record, -): T { - const parsed = parseToolArgs(schema, args); - return { - ...parsed, - projectDir: validateProjectDir(parsed.projectDir), - }; -} - -function isWorkflowToolExecutors( - value: unknown, -): value is WorkflowToolExecutors { - if (!value || typeof value !== "object") return false; - const record = value as Record; - const functionExports = [ - "executeMilestoneStatus", - "executePlanMilestone", - "executePlanSlice", - "executeReplanSlice", - "executeSliceComplete", - "executeCompleteMilestone", - "executeValidateMilestone", - "executeReassessRoadmap", - "executeSaveGateResult", - "executeSummarySave", - "executeTaskComplete", - ]; - - return ( - Array.isArray(record.SUPPORTED_SUMMARY_ARTIFACT_TYPES) && - functionExports.every((key) => typeof record[key] === "function") - ); -} - -function getSupportedSummaryArtifactTypes( - executors: WorkflowToolExecutors, -): readonly string[] { - return executors.SUPPORTED_SUMMARY_ARTIFACT_TYPES; -} - -function getWriteGateModuleCandidates(): string[] { - const candidates: string[] = []; - const explicitModule = process.env.SF_WORKFLOW_WRITE_GATE_MODULE?.trim(); - if (explicitModule) { - if ( - /^[a-z]{2,}:/i.test(explicitModule) && - !explicitModule.startsWith("file:") - ) { - throw new Error( - "SF_WORKFLOW_WRITE_GATE_MODULE only supports file: URLs or filesystem paths.", - ); - } - candidates.push( - explicitModule.startsWith("file:") - ? explicitModule - : toFileUrl(explicitModule), - ); - } - - candidates.push( - new URL( - "../../../src/resources/extensions/sf/bootstrap/write-gate.js", - import.meta.url, - ).href, - new URL( - "../../../dist/resources/extensions/sf/bootstrap/write-gate.js", - import.meta.url, - ).href, - new URL( - "../../../src/resources/extensions/sf/bootstrap/write-gate.ts", - import.meta.url, - ).href, - ); - - return [...new Set(candidates)]; -} - -function toFileUrl(modulePath: string): string { - return pathToFileURL(resolve(modulePath)).href; -} - -/** @internal — exported for testing only */ -export function _buildImportCandidates(relativePath: string): string[] { - // Build candidate paths: try the given path first, then swap src/<->dist/ - // and try .ts extension. This handles both dev (tsx from src/) and prod - // (compiled from dist/) execution contexts. - const candidates: string[] = [relativePath]; - const swapped = relativePath.includes("/src/") - ? relativePath.replace("/src/", "/dist/") - : relativePath.includes("/dist/") - ? relativePath.replace("/dist/", "/src/") - : null; - if (swapped) candidates.push(swapped); - // Also try .ts variants for dev-mode tsx execution - if (relativePath.endsWith(".js")) { - candidates.push(relativePath.replace(/\.js$/, ".ts")); - if (swapped) candidates.push(swapped.replace(/\.js$/, ".ts")); - } - return candidates; -} - -async function importLocalModule(relativePath: string): Promise { - const candidates = _buildImportCandidates(relativePath).map( - (p) => new URL(p, import.meta.url).href, - ); - - let lastErr: unknown; - for (const candidate of candidates) { - try { - return (await import(candidate)) as T; - } catch (err) { - lastErr = err; - } - } - throw lastErr; -} - -function getWorkflowExecutorModuleCandidates( - env: NodeJS.ProcessEnv = process.env, -): string[] { - const candidates: string[] = []; - const explicitModule = env.SF_WORKFLOW_EXECUTORS_MODULE?.trim(); - if (explicitModule) { - if ( - /^[a-z]{2,}:/i.test(explicitModule) && - !explicitModule.startsWith("file:") - ) { - throw new Error( - "SF_WORKFLOW_EXECUTORS_MODULE only supports file: URLs or filesystem paths.", - ); - } - candidates.push( - explicitModule.startsWith("file:") - ? explicitModule - : toFileUrl(explicitModule), - ); - } - - candidates.push( - new URL( - "../../../src/resources/extensions/sf/tools/workflow-tool-executors.js", - import.meta.url, - ).href, - new URL( - "../../../dist/resources/extensions/sf/tools/workflow-tool-executors.js", - import.meta.url, - ).href, - new URL( - "../../../src/resources/extensions/sf/tools/workflow-tool-executors.ts", - import.meta.url, - ).href, - ); - - return [...new Set(candidates)]; -} - -async function getWorkflowToolExecutors(): Promise { - if (!workflowToolExecutorsPromise) { - workflowToolExecutorsPromise = (async () => { - const attempts: string[] = []; - for (const candidate of getWorkflowExecutorModuleCandidates()) { - try { - const loaded = await import(candidate); - if (isWorkflowToolExecutors(loaded)) { - return loaded; - } - attempts.push(`${candidate} (module shape mismatch)`); - } catch (err) { - attempts.push( - `${candidate} (${err instanceof Error ? err.message : String(err)})`, - ); - } - } - - throw new Error( - "Unable to load SF workflow executor bridge for MCP mutation tools. " + - "Set SF_WORKFLOW_EXECUTORS_MODULE to an importable workflow-tool-executors module, " + - "or run the MCP server from a SF checkout that includes src/resources/extensions/sf/tools/workflow-tool-executors.(js|ts). " + - `Attempts: ${attempts.join("; ")}`, - ); - })(); - } - return workflowToolExecutorsPromise; -} - -async function getWorkflowWriteGateModule(): Promise { - if (!workflowWriteGatePromise) { - workflowWriteGatePromise = (async () => { - const attempts: string[] = []; - for (const candidate of getWriteGateModuleCandidates()) { - try { - const loaded = await import(candidate); - if ( - loaded && - typeof loaded.loadWriteGateSnapshot === "function" && - typeof loaded.shouldBlockPendingGateInSnapshot === "function" && - typeof loaded.shouldBlockQueueExecutionInSnapshot === "function" - ) { - return loaded as WorkflowWriteGateModule; - } - attempts.push(`${candidate} (module shape mismatch)`); - } catch (err) { - attempts.push( - `${candidate} (${err instanceof Error ? err.message : String(err)})`, - ); - } - } - - throw new Error( - "Unable to load SF write-gate bridge for workflow MCP tools. " + - `Attempts: ${attempts.join("; ")}`, - ); - })(); - } - return workflowWriteGatePromise; -} - -interface McpToolServer { - tool( - name: string, - description: string, - params: Record, - handler: (args: Record) => Promise, - ): unknown; -} - -export const WORKFLOW_TOOL_NAMES = [ - "sf_decision_save", - "sf_requirement_update", - "sf_requirement_save", - "sf_milestone_generate_id", - "sf_plan_milestone", - "sf_plan_slice", - "sf_plan_task", - "sf_replan_slice", - "sf_slice_complete", - "sf_skip_slice", - "sf_complete_milestone", - "sf_validate_milestone", - "sf_reassess_roadmap", - "sf_save_gate_result", - "sf_summary_save", - "sf_task_complete", - "sf_milestone_status", - "sf_journal_query", -] as const; - -async function runSerializedWorkflowOperation( - fn: () => Promise, -): Promise { - // The shared DB adapter and workflow log base path are process-global, so - // workflow MCP mutations must not overlap within a single server process. - const prior = workflowExecutionQueue; - let release!: () => void; - workflowExecutionQueue = new Promise((resolve) => { - release = resolve; - }); - - await prior; - try { - return await fn(); - } finally { - release(); - } -} - -async function runSerializedWorkflowDbOperation( - projectDir: string, - fn: () => Promise, -): Promise { - return runSerializedWorkflowOperation(async () => { - const { ensureDbOpen } = await importLocalModule( - "../../../src/resources/extensions/sf/bootstrap/dynamic-tools.js", - ); - const dbAvailable = await ensureDbOpen(projectDir); - if (!dbAvailable) { - throw new Error("SF database is not available"); - } - return fn(); - }); -} - -async function enforceWorkflowWriteGate( - toolName: string, - projectDir: string, - milestoneId: string | null = null, -): Promise { - const writeGate = await getWorkflowWriteGateModule(); - const snapshot = writeGate.loadWriteGateSnapshot(projectDir); - const pendingGate = writeGate.shouldBlockPendingGateInSnapshot( - snapshot, - toolName, - milestoneId, - snapshot.activeQueuePhase, - ); - if (pendingGate.block) { - throw new Error( - pendingGate.reason ?? "workflow tool blocked by pending discussion gate", - ); - } - - const queueGuard = writeGate.shouldBlockQueueExecutionInSnapshot( - snapshot, - toolName, - "", - snapshot.activeQueuePhase, - ); - if (queueGuard.block) { - throw new Error( - queueGuard.reason ?? "workflow tool blocked during queue mode", - ); - } -} - -async function handleTaskComplete( - projectDir: string, - args: Omit, "projectDir">, -): Promise { - await enforceWorkflowWriteGate( - "sf_task_complete", - projectDir, - args.milestoneId, - ); - const { - taskId, - sliceId, - milestoneId, - oneLiner, - narrative, - verification, - deviations, - knownIssues, - keyFiles, - keyDecisions, - blockerDiscovered, - verificationEvidence, - } = args; - const { executeTaskComplete } = await getWorkflowToolExecutors(); - return runSerializedWorkflowOperation(() => - executeTaskComplete( - { - taskId, - sliceId, - milestoneId, - oneLiner, - narrative, - verification, - deviations, - knownIssues, - keyFiles, - keyDecisions, - blockerDiscovered, - verificationEvidence, - }, - projectDir, - ), - ); -} - -async function handleSliceComplete( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_slice_complete", - projectDir, - args.milestoneId, - ); - const { executeSliceComplete } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeSliceComplete(params, projectDir), - ); -} - -async function handleReplanSlice( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_replan_slice", - projectDir, - args.milestoneId, - ); - const { executeReplanSlice } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeReplanSlice(params, projectDir), - ); -} - -async function handleCompleteMilestone( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_complete_milestone", - projectDir, - args.milestoneId, - ); - const { executeCompleteMilestone } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeCompleteMilestone(params, projectDir), - ); -} - -async function handleValidateMilestone( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_validate_milestone", - projectDir, - args.milestoneId, - ); - const { executeValidateMilestone } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeValidateMilestone(params, projectDir), - ); -} - -async function handleReassessRoadmap( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_reassess_roadmap", - projectDir, - args.milestoneId, - ); - const { executeReassessRoadmap } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeReassessRoadmap(params, projectDir), - ); -} - -async function handleSaveGateResult( - projectDir: string, - args: z.infer, -): Promise { - await enforceWorkflowWriteGate( - "sf_save_gate_result", - projectDir, - args.milestoneId, - ); - const { executeSaveGateResult } = await getWorkflowToolExecutors(); - const { projectDir: _projectDir, ...params } = args; - return runSerializedWorkflowOperation(() => - executeSaveGateResult(params, projectDir), - ); -} - -async function ensureMilestoneDbRow(milestoneId: string): Promise { - try { - const { insertMilestone } = await importLocalModule( - "../../../src/resources/extensions/sf/sf-db.js", - ); - insertMilestone({ id: milestoneId, status: "queued" }); - } catch { - // Ignore pre-existing rows or transient DB availability issues. - } -} - -const projectDirParam = z - .string() - .describe( - "Absolute path to the project directory within the configured workflow root", - ); - -const planMilestoneParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - title: z.string().describe("Milestone title"), - vision: z.string().describe("Milestone vision"), - slices: z - .array( - z.object({ - sliceId: z.string(), - title: z.string(), - risk: z.string(), - depends: z.array(z.string()), - demo: z.string(), - goal: z.string(), - successCriteria: z.string(), - proofLevel: z.string(), - integrationClosure: z.string(), - observabilityImpact: z.string(), - }), - ) - .optional() - .describe( - "Planned slices for the milestone. Optional when templateId is used for scaffolding.", - ), - templateId: z - .string() - .optional() - .describe( - "Optional milestone template scaffold (e.g. bugfix, small-feature, refactor)", - ), - status: z.string().optional().describe("Milestone status"), - dependsOn: z.array(z.string()).optional().describe("Milestone dependencies"), - successCriteria: z - .array(z.string()) - .optional() - .describe("Top-level success criteria bullets"), - keyRisks: z - .array( - z.object({ - risk: z.string(), - whyItMatters: z.string(), - }), - ) - .optional() - .describe("Structured risk entries"), - proofStrategy: z - .array( - z.object({ - riskOrUnknown: z.string(), - retireIn: z.string(), - whatWillBeProven: z.string(), - }), - ) - .optional() - .describe("Structured proof strategy entries"), - verificationContract: z.string().optional(), - verificationIntegration: z.string().optional(), - verificationOperational: z.string().optional(), - verificationUat: z.string().optional(), - definitionOfDone: z.array(z.string()).optional(), - requirementCoverage: z.string().optional(), - boundaryMapMarkdown: z.string().optional(), - visionMeeting: z - .object({ - trigger: z.string(), - pm: z.string(), - userAdvocate: z.string(), - customerPanel: z.string(), - business: z.string(), - researcher: z.string(), - deliveryLead: z.string(), - partner: z.string(), - combatant: z.string(), - architect: z.string(), - moderator: z.string(), - weightedSynthesis: z.string(), - confidenceByArea: z.string(), - recommendedRoute: z.enum(["discussing", "researching", "planning"]), - }) - .optional() - .describe( - "Structured top-level vision and roadmap alignment meeting with weighted synthesis", - ), -}; -const planMilestoneSchema = z.object(planMilestoneParams); - -const planSliceParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - sliceId: z.string().describe("Slice ID (e.g. S01)"), - goal: z.string().describe("Slice goal"), - adversarialReview: z - .object({ - partner: z.string(), - combatant: z.string(), - architect: z.string(), - }) - .optional() - .describe( - "Adversarial review summary with partner, combatant, and architect perspectives", - ), - planningMeeting: z - .object({ - trigger: z.string(), - pm: z.string(), - userAdvocate: z.string().optional(), - customerPanel: z.string().optional(), - business: z.string().optional(), - researcher: z.string(), - deliveryLead: z.string().optional(), - partner: z.string(), - combatant: z.string(), - architect: z.string(), - moderator: z.string(), - recommendedRoute: z.enum(["discussing", "researching", "planning"]), - confidenceSummary: z.string(), - }) - .describe( - "Required populated planning meeting. Empty, null, or missing planningMeeting is not acceptable.", - ), - tasks: z - .array( - z.object({ - taskId: z.string(), - title: z.string(), - description: z.string(), - estimate: z.string(), - files: z.array(z.string()), - verify: z.string(), - inputs: z.array(z.string()), - expectedOutput: z.array(z.string()), - observabilityImpact: z.string().optional(), - }), - ) - .describe("Planned tasks for the slice"), - successCriteria: z.string().optional(), - proofLevel: z.string().optional(), - integrationClosure: z.string().optional(), - observabilityImpact: z.string().optional(), -}; -const planSliceSchema = z.object(planSliceParams); - -const completeMilestoneParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - title: z.string().describe("Milestone title"), - oneLiner: z - .string() - .describe("One-sentence summary of what the milestone achieved"), - narrative: z - .string() - .describe("Detailed narrative of what happened during the milestone"), - verificationPassed: z - .boolean() - .describe("Must be true after milestone verification succeeds"), - successCriteriaResults: z.string().optional(), - definitionOfDoneResults: z.string().optional(), - requirementOutcomes: z.string().optional(), - keyDecisions: z.array(z.string()).optional(), - keyFiles: z.array(z.string()).optional(), - lessonsLearned: z.array(z.string()).optional(), - followUps: z.string().optional(), - deviations: z.string().optional(), -}; -const completeMilestoneSchema = z.object(completeMilestoneParams); - -const validateMilestoneParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - verdict: z - .enum(["pass", "needs-attention", "needs-remediation"]) - .describe("Validation verdict"), - remediationRound: z - .number() - .describe("Remediation round (0 for first validation)"), - successCriteriaChecklist: z - .string() - .describe("Markdown checklist of success criteria with evidence"), - sliceDeliveryAudit: z - .string() - .describe("Markdown auditing each slice's claimed vs delivered output"), - crossSliceIntegration: z - .string() - .describe("Markdown describing cross-slice issues or closure"), - requirementCoverage: z - .string() - .describe("Markdown describing requirement coverage and gaps"), - verificationClasses: z.string().optional(), - verdictRationale: z.string().describe("Why this verdict was chosen"), - remediationPlan: z.string().optional(), -}; -const validateMilestoneSchema = z.object(validateMilestoneParams); - -const roadmapSliceChangeSchema = z.object({ - sliceId: z.string(), - title: z.string(), - risk: z.string().optional(), - depends: z.array(z.string()).optional(), - demo: z.string().optional(), -}); - -const reassessRoadmapParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - completedSliceId: z.string().describe("Slice ID that just completed"), - verdict: z - .string() - .describe( - "Assessment verdict such as roadmap-confirmed or roadmap-adjusted", - ), - assessment: z - .string() - .describe("Assessment text explaining the roadmap decision"), - sliceChanges: z - .object({ - modified: z.array(roadmapSliceChangeSchema), - added: z.array(roadmapSliceChangeSchema), - removed: z.array(z.string()), - }) - .describe("Slice changes to apply"), -}; -const reassessRoadmapSchema = z.object(reassessRoadmapParams); - -const saveGateResultParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - sliceId: z.string().describe("Slice ID (e.g. S01)"), - gateId: z - .enum(["Q3", "Q4", "Q5", "Q6", "Q7", "Q8", "MV01", "MV02", "MV03", "MV04"]) - .describe("Gate ID"), - taskId: z.string().optional().describe("Task ID for task-scoped gates"), - verdict: z.enum(["pass", "flag", "omitted"]).describe("Gate verdict"), - rationale: z.string().describe("One-sentence justification"), - findings: z.string().optional().describe("Detailed markdown findings"), -}; -const saveGateResultSchema = z.object(saveGateResultParams); - -const replanSliceParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - sliceId: z.string().describe("Slice ID (e.g. S01)"), - blockerTaskId: z.string().describe("Task ID that discovered the blocker"), - blockerDescription: z.string().describe("Description of the blocker"), - whatChanged: z.string().describe("Summary of what changed in the plan"), - goal: z - .string() - .optional() - .describe("Updated slice goal when the replan changes the slice contract"), - successCriteria: z - .string() - .optional() - .describe("Updated slice success criteria block"), - proofLevel: z.string().optional().describe("Updated slice proof level"), - integrationClosure: z - .string() - .optional() - .describe("Updated slice integration closure"), - observabilityImpact: z - .string() - .optional() - .describe("Updated slice observability impact"), - adversarialReview: z - .object({ - partner: z.string(), - combatant: z.string(), - architect: z.string(), - }) - .optional() - .describe("Updated adversarial review summary for the replanned slice"), - planningMeeting: z - .object({ - trigger: z.string(), - pm: z.string(), - researcher: z.string(), - partner: z.string(), - combatant: z.string(), - architect: z.string(), - moderator: z.string(), - recommendedRoute: z.enum(["discussing", "researching", "planning"]), - confidenceSummary: z.string(), - }) - .optional() - .describe( - "Updated structured planning meeting artifact for the replanned slice", - ), - updatedTasks: z - .array( - z.object({ - taskId: z.string(), - title: z.string(), - description: z.string(), - estimate: z.string(), - files: z.array(z.string()), - verify: z.string(), - inputs: z.array(z.string()), - expectedOutput: z.array(z.string()), - fullPlanMd: z.string().optional(), - }), - ) - .describe("Tasks to upsert into the replanned slice"), - removedTaskIds: z - .array(z.string()) - .describe("Task IDs to remove from the slice"), -}; -const replanSliceSchema = z.object(replanSliceParams); - -const sliceCompleteParams = { - projectDir: projectDirParam, - sliceId: z.string().describe("Slice ID (e.g. S01)"), - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - sliceTitle: z.string().describe("Title of the slice"), - oneLiner: z - .string() - .describe("One-line summary of what the slice accomplished"), - narrative: z - .string() - .describe("Detailed narrative of what happened across all tasks"), - verification: z.string().describe("What was verified across all tasks"), - uatContent: z.string().describe("UAT test content (markdown body)"), - deviations: z.string().optional(), - knownLimitations: z.string().optional(), - followUps: z.string().optional(), - keyFiles: z.union([z.array(z.string()), z.string()]).optional(), - keyDecisions: z.union([z.array(z.string()), z.string()]).optional(), - patternsEstablished: z.union([z.array(z.string()), z.string()]).optional(), - observabilitySurfaces: z.union([z.array(z.string()), z.string()]).optional(), - provides: z.union([z.array(z.string()), z.string()]).optional(), - requirementsSurfaced: z.union([z.array(z.string()), z.string()]).optional(), - drillDownPaths: z.union([z.array(z.string()), z.string()]).optional(), - affects: z.union([z.array(z.string()), z.string()]).optional(), - requirementsAdvanced: z - .array(z.union([z.object({ id: z.string(), how: z.string() }), z.string()])) - .optional(), - requirementsValidated: z - .array( - z.union([z.object({ id: z.string(), proof: z.string() }), z.string()]), - ) - .optional(), - requirementsInvalidated: z - .array( - z.union([z.object({ id: z.string(), what: z.string() }), z.string()]), - ) - .optional(), - filesModified: z - .array( - z.union([ - z.object({ path: z.string(), description: z.string() }), - z.string(), - ]), - ) - .optional(), - requires: z - .array( - z.union([ - z.object({ slice: z.string(), provides: z.string() }), - z.string(), - ]), - ) - .optional(), -}; -const sliceCompleteSchema = z.object(sliceCompleteParams); - -const summarySaveParams = { - projectDir: projectDirParam, - milestone_id: z.string().describe("Milestone ID (e.g. M001)"), - slice_id: z.string().optional().describe("Slice ID (e.g. S01)"), - task_id: z.string().optional().describe("Task ID (e.g. T01)"), - artifact_type: z - .string() - .describe( - "Artifact type to save (SUMMARY, RESEARCH, CONTEXT, ASSESSMENT, CONTEXT-DRAFT)", - ), - content: z.string().describe("The full markdown content of the artifact"), -}; -const summarySaveSchema = z.object(summarySaveParams); - -const decisionSaveParams = { - projectDir: projectDirParam, - scope: z - .string() - .describe( - "Scope of the decision (e.g. architecture, library, observability)", - ), - decision: z.string().describe("What is being decided"), - choice: z.string().describe("The choice made"), - rationale: z.string().describe("Why this choice was made"), - revisable: z.string().optional().describe("Whether this can be revisited"), - when_context: z.string().optional().describe("When/context for the decision"), - made_by: z - .enum(["human", "agent", "collaborative"]) - .optional() - .describe("Who made the decision"), -}; -const decisionSaveSchema = z.object(decisionSaveParams); - -const requirementUpdateParams = { - projectDir: projectDirParam, - id: z.string().describe("Requirement ID (e.g. R001)"), - status: z.string().optional().describe("New status"), - validation: z.string().optional().describe("Validation criteria or proof"), - notes: z.string().optional().describe("Additional notes"), - description: z.string().optional().describe("Updated description"), - primary_owner: z.string().optional().describe("Primary owning slice"), - supporting_slices: z.string().optional().describe("Supporting slices"), -}; -const requirementUpdateSchema = z.object(requirementUpdateParams); - -const requirementSaveParams = { - projectDir: projectDirParam, - class: z.string().describe("Requirement class"), - description: z.string().describe("Short description of the requirement"), - why: z.string().describe("Why this requirement matters"), - source: z.string().describe("Origin of the requirement"), - status: z.string().optional().describe("Requirement status"), - primary_owner: z.string().optional().describe("Primary owning slice"), - supporting_slices: z.string().optional().describe("Supporting slices"), - validation: z.string().optional().describe("Validation criteria"), - notes: z.string().optional().describe("Additional notes"), -}; -const requirementSaveSchema = z.object(requirementSaveParams); - -const milestoneGenerateIdParams = { - projectDir: projectDirParam, -}; -const milestoneGenerateIdSchema = z.object(milestoneGenerateIdParams); - -const planTaskParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - sliceId: z.string().describe("Slice ID (e.g. S01)"), - taskId: z.string().describe("Task ID (e.g. T01)"), - title: z.string().describe("Task title"), - description: z.string().describe("Task description / steps block"), - estimate: z.string().describe("Task estimate"), - files: z.array(z.string()).describe("Files likely touched"), - verify: z.string().describe("Verification command or block"), - inputs: z.array(z.string()).describe("Input files or references"), - expectedOutput: z - .array(z.string()) - .describe("Expected output files or artifacts"), - observabilityImpact: z - .string() - .optional() - .describe("Task observability impact"), -}; -const planTaskSchema = z.object(planTaskParams); - -const skipSliceParams = { - projectDir: projectDirParam, - sliceId: z.string().describe("Slice ID (e.g. S02)"), - milestoneId: z.string().describe("Milestone ID (e.g. M003)"), - reason: z.string().optional().describe("Reason for skipping this slice"), -}; -const skipSliceSchema = z.object(skipSliceParams); - -const taskCompleteParams = { - projectDir: projectDirParam, - taskId: z.string().describe("Task ID (e.g. T01)"), - sliceId: z.string().describe("Slice ID (e.g. S01)"), - milestoneId: z.string().describe("Milestone ID (e.g. M001)"), - oneLiner: z.string().describe("One-line summary of what was accomplished"), - narrative: z - .string() - .describe("Detailed narrative of what happened during the task"), - verification: z.string().describe("What was verified and how"), - deviations: z.string().optional().describe("Deviations from the task plan"), - knownIssues: z - .string() - .optional() - .describe("Known issues discovered but not fixed"), - keyFiles: z - .array(z.string()) - .optional() - .describe("List of key files created or modified"), - keyDecisions: z - .array(z.string()) - .optional() - .describe("List of key decisions made during this task"), - blockerDiscovered: z - .boolean() - .optional() - .describe("Whether a plan-invalidating blocker was discovered"), - verificationEvidence: z - .array( - z.union([ - z.object({ - command: z.string(), - exitCode: z.number(), - verdict: z.string(), - durationMs: z.number(), - }), - z.string(), - ]), - ) - .optional() - .describe("Verification evidence entries"), -}; -const taskCompleteSchema = z.object(taskCompleteParams); - -const milestoneStatusParams = { - projectDir: projectDirParam, - milestoneId: z.string().describe("Milestone ID to query (e.g. M001)"), -}; -const milestoneStatusSchema = z.object(milestoneStatusParams); - -const journalQueryParams = { - projectDir: projectDirParam, - flowId: z.string().optional().describe("Filter by flow ID"), - unitId: z.string().optional().describe("Filter by unit ID"), - rule: z.string().optional().describe("Filter by rule name"), - eventType: z.string().optional().describe("Filter by event type"), - after: z.string().optional().describe("ISO-8601 lower bound (inclusive)"), - before: z.string().optional().describe("ISO-8601 upper bound (inclusive)"), - limit: z.number().optional().describe("Maximum entries to return"), -}; -const journalQuerySchema = z.object(journalQueryParams); - -export function registerWorkflowTools(server: McpToolServer): void { - server.tool( - "sf_decision_save", - "Record a project decision to the SF database and regenerate DECISIONS.md.", - decisionSaveParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(decisionSaveSchema, args); - const { projectDir, ...params } = parsed; - await enforceWorkflowWriteGate("sf_decision_save", projectDir); - const result = await runSerializedWorkflowDbOperation( - projectDir, - async () => { - const { saveDecisionToDb } = await importLocalModule( - "../../../src/resources/extensions/sf/db-writer.js", - ); - return saveDecisionToDb(params, projectDir); - }, - ); - return { - content: [ - { type: "text" as const, text: `Saved decision ${result.id}` }, - ], - }; - }, - ); - - server.tool( - "sf_requirement_update", - "Update an existing requirement in the SF database and regenerate REQUIREMENTS.md.", - requirementUpdateParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(requirementUpdateSchema, args); - const { projectDir, id, ...updates } = parsed; - await enforceWorkflowWriteGate("sf_requirement_update", projectDir); - await runSerializedWorkflowDbOperation(projectDir, async () => { - const { updateRequirementInDb } = await importLocalModule( - "../../../src/resources/extensions/sf/db-writer.js", - ); - return updateRequirementInDb(id, updates, projectDir); - }); - return { - content: [{ type: "text" as const, text: `Updated requirement ${id}` }], - }; - }, - ); - - server.tool( - "sf_requirement_save", - "Record a new requirement to the SF database and regenerate REQUIREMENTS.md.", - requirementSaveParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(requirementSaveSchema, args); - const { projectDir, ...params } = parsed; - await enforceWorkflowWriteGate("sf_requirement_save", projectDir); - const result = await runSerializedWorkflowDbOperation( - projectDir, - async () => { - const { saveRequirementToDb } = await importLocalModule( - "../../../src/resources/extensions/sf/db-writer.js", - ); - return saveRequirementToDb(params, projectDir); - }, - ); - return { - content: [ - { type: "text" as const, text: `Saved requirement ${result.id}` }, - ], - }; - }, - ); - - server.tool( - "sf_milestone_generate_id", - "Generate the next milestone ID for a new SF milestone.", - milestoneGenerateIdParams, - async (args: Record) => { - const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args); - await enforceWorkflowWriteGate("sf_milestone_generate_id", projectDir); - const id = await runSerializedWorkflowDbOperation( - projectDir, - async () => { - const { - claimReservedId, - findMilestoneIds, - getReservedMilestoneIds, - nextMilestoneId, - } = await importLocalModule( - "../../../src/resources/extensions/sf/milestone-ids.js", - ); - const reserved = claimReservedId(); - if (reserved) { - await ensureMilestoneDbRow(reserved); - return reserved; - } - const allIds = [ - ...new Set([ - ...findMilestoneIds(projectDir), - ...getReservedMilestoneIds(), - ]), - ]; - const nextId = nextMilestoneId(allIds); - await ensureMilestoneDbRow(nextId); - return nextId; - }, - ); - return { content: [{ type: "text" as const, text: id }] }; - }, - ); - - server.tool( - "sf_plan_milestone", - "Write milestone planning state to the SF database and render ROADMAP.md from DB.", - planMilestoneParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(planMilestoneSchema, args); - const { projectDir, ...params } = parsed; - await enforceWorkflowWriteGate( - "sf_plan_milestone", - projectDir, - params.milestoneId, - ); - const { executePlanMilestone } = await getWorkflowToolExecutors(); - return runSerializedWorkflowOperation(() => - executePlanMilestone(params, projectDir), - ); - }, - ); - - server.tool( - "sf_plan_slice", - "Write slice/task planning state to the SF database and render plan artifacts from DB.", - planSliceParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(planSliceSchema, args); - const { projectDir, ...params } = parsed; - await enforceWorkflowWriteGate( - "sf_plan_slice", - projectDir, - params.milestoneId, - ); - const { executePlanSlice } = await getWorkflowToolExecutors(); - return runSerializedWorkflowOperation(() => - executePlanSlice(params, projectDir), - ); - }, - ); - - server.tool( - "sf_plan_task", - "Write task planning state to the SF database and render tasks/T##-PLAN.md from DB.", - planTaskParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(planTaskSchema, args); - const { projectDir, ...params } = parsed; - await enforceWorkflowWriteGate( - "sf_plan_task", - projectDir, - params.milestoneId, - ); - const result = await runSerializedWorkflowDbOperation( - projectDir, - async () => { - const { handlePlanTask } = await importLocalModule( - "../../../src/resources/extensions/sf/tools/plan-task.js", - ); - return handlePlanTask(params, projectDir); - }, - ); - if ("error" in result) { - throw new Error(result.error); - } - return { - content: [ - { - type: "text" as const, - text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})`, - }, - ], - }; - }, - ); - - server.tool( - "sf_replan_slice", - "Replan a slice after a blocker is discovered, preserving completed tasks and re-rendering PLAN.md + REPLAN.md.", - replanSliceParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(replanSliceSchema, args); - return handleReplanSlice(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_slice_complete", - "Record a completed slice to the SF database, render SUMMARY.md + UAT.md, and update roadmap projection.", - sliceCompleteParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(sliceCompleteSchema, args); - return handleSliceComplete(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_skip_slice", - "Mark a slice as skipped so auto-mode advances past it without executing.", - skipSliceParams, - async (args: Record) => { - const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs( - skipSliceSchema, - args, - ); - await enforceWorkflowWriteGate("sf_skip_slice", projectDir, milestoneId); - await runSerializedWorkflowDbOperation(projectDir, async () => { - const { getSlice, updateSliceStatus } = await importLocalModule( - "../../../src/resources/extensions/sf/sf-db.js", - ); - const { invalidateStateCache } = await importLocalModule( - "../../../src/resources/extensions/sf/state.js", - ); - const { rebuildState } = await importLocalModule( - "../../../src/resources/extensions/sf/doctor.js", - ); - const slice = getSlice(milestoneId, sliceId); - if (!slice) { - throw new Error( - `Slice ${sliceId} not found in milestone ${milestoneId}`, - ); - } - if (slice.status === "complete" || slice.status === "done") { - throw new Error( - `Slice ${sliceId} is already complete and cannot be skipped`, - ); - } - if (slice.status !== "skipped") { - updateSliceStatus(milestoneId, sliceId, "skipped"); - invalidateStateCache(); - await rebuildState(projectDir); - } - }); - return { - content: [ - { - type: "text" as const, - text: `Skipped slice ${sliceId} (${milestoneId}). Reason: ${reason ?? "User-directed skip"}.`, - }, - ], - }; - }, - ); - - server.tool( - "sf_complete_milestone", - "Record a completed milestone to the SF database and render its SUMMARY.md.", - completeMilestoneParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(completeMilestoneSchema, args); - return handleCompleteMilestone(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_validate_milestone", - "Validate a milestone, persist validation results to the SF database, and render VALIDATION.md.", - validateMilestoneParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(validateMilestoneSchema, args); - return handleValidateMilestone(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_reassess_roadmap", - "Reassess a milestone roadmap after a slice completes, writing ASSESSMENT.md and re-rendering ROADMAP.md.", - reassessRoadmapParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(reassessRoadmapSchema, args); - return handleReassessRoadmap(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_save_gate_result", - "Save a quality gate result to the SF database.", - saveGateResultParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(saveGateResultSchema, args); - return handleSaveGateResult(parsed.projectDir, parsed); - }, - ); - - server.tool( - "sf_summary_save", - "Save a SF summary/research/context/assessment artifact to the database and disk.", - summarySaveParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(summarySaveSchema, args); - const { - projectDir, - milestone_id, - slice_id, - task_id, - artifact_type, - content, - } = parsed; - await enforceWorkflowWriteGate( - "sf_summary_save", - projectDir, - milestone_id, - ); - const executors = await getWorkflowToolExecutors(); - const supportedArtifactTypes = - getSupportedSummaryArtifactTypes(executors); - if (!supportedArtifactTypes.includes(artifact_type)) { - throw new Error( - `artifact_type must be one of: ${supportedArtifactTypes.join(", ")}`, - ); - } - return runSerializedWorkflowOperation(() => - executors.executeSummarySave( - { milestone_id, slice_id, task_id, artifact_type, content }, - projectDir, - ), - ); - }, - ); - - server.tool( - "sf_task_complete", - "Record a completed task to the SF database and render its SUMMARY.md.", - taskCompleteParams, - async (args: Record) => { - const parsed = parseWorkflowArgs(taskCompleteSchema, args); - const { projectDir, ...taskArgs } = parsed; - return handleTaskComplete(projectDir, taskArgs); - }, - ); - - server.tool( - "sf_milestone_status", - "Read the current status of a milestone and all its slices from the SF database.", - milestoneStatusParams, - async (args: Record) => { - const { projectDir, milestoneId } = parseWorkflowArgs( - milestoneStatusSchema, - args, - ); - await enforceWorkflowWriteGate( - "sf_milestone_status", - projectDir, - milestoneId, - ); - const { executeMilestoneStatus } = await getWorkflowToolExecutors(); - return runSerializedWorkflowOperation(() => - executeMilestoneStatus({ milestoneId }, projectDir), - ); - }, - ); - - server.tool( - "sf_journal_query", - "Query the structured event journal for auto-mode iterations.", - journalQueryParams, - async (args: Record) => { - const { projectDir, limit, ...filters } = parseWorkflowArgs( - journalQuerySchema, - args, - ); - const { queryJournal } = await importLocalModule( - "../../../src/resources/extensions/sf/journal.js", - ); - const entries = queryJournal(projectDir, filters).slice(0, limit ?? 100); - if (entries.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "No matching journal entries found.", - }, - ], - }; - } - return { - content: [ - { type: "text" as const, text: JSON.stringify(entries, null, 2) }, - ], - }; - }, - ); -} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json deleted file mode 100644 index b7941ad79..000000000 --- a/packages/mcp-server/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "Node16", - "lib": ["ES2024"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "incremental": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "inlineSources": true, - "inlineSourceMap": false, - "moduleResolution": "Node16", - "resolveJsonModule": true, - "allowImportingTsExtensions": false, - "types": ["node"], - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*.ts"], - "exclude": [ - "node_modules", - "dist", - "**/*.d.ts", - "src/**/*.d.ts", - "src/**/*.test.ts" - ] -} diff --git a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts index eedfdb37f..1c20fab7c 100644 --- a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +++ b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts @@ -206,7 +206,7 @@ test("chat-controller renders serverToolUse before trailing text matching conten const serverToolUse = { type: "serverToolUse", id: toolId, - name: "mcp__sf-workflow__secure_env_collect", + name: "mcp__external-tools__secure_env_collect", input: { projectDir: "/tmp/project", keys: [{ key: "SECURE_PASSWORD" }], @@ -399,7 +399,7 @@ test("chat-controller keeps pre-tool prose visible until post-tool prose arrives } as any); }); -test("chat-controller keeps pre-tool thinking visible for claude-code MCP turns without post-tool prose", async () => { +test("chat-controller keeps pre-tool thinking visible for adapter MCP turns without post-tool prose", async () => { (globalThis as any)[Symbol.for("@singularity-forge/pi-coding-agent:theme")] = { fg: (_key: string, text: string) => text, @@ -482,7 +482,7 @@ test("chat-controller keeps pre-tool thinking visible for claude-code MCP turns } as any); }); -test("chat-controller prunes orphaned provisional text after claude-code sub-turn shrink when MCP tools appear", async () => { +test("chat-controller prunes orphaned provisional text after adapter sub-turn shrink when MCP tools appear", async () => { (globalThis as any)[Symbol.for("@singularity-forge/pi-coding-agent:theme")] = { fg: (_key: string, text: string) => text, diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 73db2da9b..8a7c695fd 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -61,7 +61,7 @@ function str(value: unknown): string | null { } /** - * Split a Claude Code MCP tool name (`mcp____`) into its parts. + * Split an adapter-surfaced MCP tool name (`mcp____`) into its parts. * Returns null for non-prefixed names. Duplicated from the claude-code-cli * extension (parseMcpToolName) so this package doesn't have to import across * the resources/extensions boundary. @@ -1319,7 +1319,7 @@ export class ToolExecutionComponent extends Container { } } else { // Generic tool / MCP tool without a registered renderer. - // MCP tool names from Claude Code arrive as `mcp____`; + // Adapter-surfaced MCP tool names arrive as `mcp____`; // render the server prefix in muted style so the tool name reads // cleanly. SF-registered MCP tools have already had their prefix // stripped upstream in partial-builder.ts and won't reach this branch. diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 06d6efe0f..b49b5557e 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -447,7 +447,7 @@ export async function handleAgentEvent( b.type === "text" || b.type === "thinking" ? b.type : undefined; const isTextLike = blockType === "text" || blockType === "thinking"; const isTool = b.type === "toolCall" || b.type === "serverToolUse"; - // For Claude Code MCP turns, prune only pre-tool prose, never thinking. + // For adapter-surfaced MCP tool turns, prune only pre-tool prose, never thinking. const shouldSkipProse = shouldDropPreToolProse && firstToolIdx >= 0 && @@ -479,7 +479,7 @@ export async function handleAgentEvent( } closeRun(); - // Claude Code MCP can emit provisional pre-tool prose that gets + // Adapter-surfaced MCP tool turns can emit provisional pre-tool prose that gets // superseded by post-tool output. Prune stale text-run segments so // the final assistant output remains below tool output. if (shouldDropPreToolProse && firstToolIdx >= 0) { diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts index 0f59cd16d..e335def7f 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -152,7 +152,7 @@ export function setupEditorSubmitHandler( * Drag-and-drop inserts paths like "/Users/name/Desktop/file.png" which * should be treated as plain text input, not a /Users command. * - * Heuristic: a slash command is a single token like "/help" or "/sf auto". + * Heuristic: a slash command is a single token like "/help" or "/sf autonomous". * File paths have a second "/" within the first token (e.g., "/Users/..."). */ function looksLikeFilePath(text: string): boolean { diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 462033ffb..537457ac1 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -30,7 +30,6 @@ console.log(`[bump-version] package.json: ${oldVersion} → ${newVersion}`); // published and have their own lifecycle. const workspacePackages = [ "daemon", - "mcp-server", "native", "pi-agent-core", "pi-ai", diff --git a/scripts/compile-tests.mjs b/scripts/compile-tests.mjs index edf19f833..444f50927 100644 --- a/scripts/compile-tests.mjs +++ b/scripts/compile-tests.mjs @@ -157,7 +157,7 @@ async function main() { // Copy root dist/ into dist-test/dist/ — some tests compute projectRoot as // 3 levels up from dist-test/src/tests/ which lands at dist-test/, then - // import from dist/mcp-server.js etc. + // import from dist package entrypoints etc. const rootDistDir = join(ROOT, "dist"); const distTestDistDir = join(ROOT, "dist-test", "dist"); await copyAssets(rootDistDir, distTestDistDir); diff --git a/scripts/ensure-workspace-builds.cjs b/scripts/ensure-workspace-builds.cjs index ac9493610..a90958d7a 100644 --- a/scripts/ensure-workspace-builds.cjs +++ b/scripts/ensure-workspace-builds.cjs @@ -99,7 +99,6 @@ if (require.main === module) { "pi-coding-agent", "rpc-client", "daemon", - "mcp-server", ]; const stale = detectStalePackages(root, WORKSPACE_PACKAGES); diff --git a/scripts/generate-features-inventory.mjs b/scripts/generate-features-inventory.mjs index 485e4bce8..39148b8ae 100644 --- a/scripts/generate-features-inventory.mjs +++ b/scripts/generate-features-inventory.mjs @@ -6,13 +6,6 @@ const __dirname = import.meta.dirname; const repoRoot = resolve(__dirname, ".."); const featuresPath = join(repoRoot, "FEATURES.md"); -const workflowToolsPath = join( - repoRoot, - "packages", - "mcp-server", - "src", - "workflow-tools.ts", -); const providersPath = join(repoRoot, "packages", "pi-ai", "src", "types.ts"); const extensionsRoot = join(repoRoot, "src", "resources", "extensions"); const searchProviderPath = resolveExistingPath( @@ -51,14 +44,6 @@ function resolveExistingPath(...paths) { return found; } -export function parseWorkflowToolNames() { - const src = readFileSync(workflowToolsPath, "utf8"); - const matches = [...src.matchAll(/server\.tool\(\s*"([^"]+)"/g)].map( - (m) => m[1], - ); - return uniqueSorted(matches); -} - export function parseKnownProviders() { const src = readFileSync(providersPath, "utf8"); const match = src.match(/export type KnownProvider =([\s\S]*?);/); @@ -114,18 +99,11 @@ function formatBullets(values, formatter = (value) => `- \`${value}\``) { } export function buildSection() { - const workflowTools = parseWorkflowToolNames(); const extensions = parseBundledExtensions(); const searchProviders = parseSearchProviders(); const knownProviders = parseKnownProviders(); return [ - "### Workflow Tools", - "", - "Generated from `packages/mcp-server/src/workflow-tools.ts`.", - "", - formatBullets(workflowTools), - "", "### Bundled Extensions", "", "Generated from `src/resources/extensions/*/extension-manifest.json`.", diff --git a/scripts/link-workspace-packages.cjs b/scripts/link-workspace-packages.cjs index ebcf26dcc..3db70a22a 100644 --- a/scripts/link-workspace-packages.cjs +++ b/scripts/link-workspace-packages.cjs @@ -41,7 +41,6 @@ const packageDirs = [ "pi-tui", "rpc-client", "daemon", - "mcp-server", ]; if (!existsSync(scopeDir)) { diff --git a/scripts/validate-pack.js b/scripts/validate-pack.js index 421292b56..c0d96513b 100644 --- a/scripts/validate-pack.js +++ b/scripts/validate-pack.js @@ -122,7 +122,6 @@ try { "packages/pi-coding-agent/dist/index.js", "packages/rpc-client/dist/index.js", "packages/daemon/dist/cli.js", - "packages/mcp-server/dist/cli.js", "scripts/link-workspace-packages.cjs", "dist/web/standalone/server.js", ]; @@ -225,23 +224,11 @@ try { "dist", "cli.js", ); - const bundledWorkflowMcpCliPath = join( - installedRoot, - "packages", - "mcp-server", - "dist", - "cli.js", - ); if (!existsSync(daemonCliPath)) { console.log("ERROR: Bundled daemon CLI missing after install."); console.log(` Expected: ${daemonCliPath}`); process.exit(1); } - if (!existsSync(bundledWorkflowMcpCliPath)) { - console.log("ERROR: Bundled workflow MCP CLI missing after install."); - console.log(` Expected: ${bundledWorkflowMcpCliPath}`); - process.exit(1); - } try { const versionOutput = execFileSync(process.execPath, [loaderPath, "-v"], { cwd: installDir, diff --git a/src/cli-web-branch.ts b/src/cli-web-branch.ts index ceb462d9d..a73b69298 100644 --- a/src/cli-web-branch.ts +++ b/src/cli-web-branch.ts @@ -21,7 +21,7 @@ import { } from "./web-mode.js"; export interface CliFlags { - mode?: "text" | "json" | "rpc" | "mcp"; + mode?: "text" | "json" | "rpc"; print?: boolean; continue?: boolean; noSession?: boolean; @@ -71,12 +71,7 @@ export function parseCliArgs(argv: string[]): CliFlags { const arg = args[i]; if (arg === "--mode" && i + 1 < args.length) { const mode = args[++i]; - if ( - mode === "text" || - mode === "json" || - mode === "rpc" || - mode === "mcp" - ) + if (mode === "text" || mode === "json" || mode === "rpc") flags.mode = mode; } else if (arg === "--print" || arg === "-p") { flags.print = true; diff --git a/src/cli.ts b/src/cli.ts index 9c647b4cc..45a1b1acd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -89,7 +89,7 @@ function printNonTtyErrorAndExit( ); process.stderr.write("[sf] Non-interactive alternatives:\n"); process.stderr.write( - "[sf] sf auto Auto-mode (pipeable, no TUI)\n", + "[sf] sf autonomous Autonomous mode (pipeable, no TUI)\n", ); process.stderr.write( '[sf] sf --print "your message" Single-shot prompt\n', @@ -102,15 +102,12 @@ function printNonTtyErrorAndExit( process.stderr.write( "[sf] sf --mode rpc JSON-RPC over stdin/stdout\n", ); - process.stderr.write( - "[sf] sf --mode mcp MCP server over stdin/stdout\n", - ); process.stderr.write( '[sf] sf --mode text "message" Text output mode\n', ); if (includeWebHint) { process.stderr.write( - "[sf] sf headless Auto-mode without TUI\n", + "[sf] sf headless Autonomous mode without TUI\n", ); } process.exit(1); @@ -552,7 +549,7 @@ if (cliFlags.messages[0] === "sessions") { cliFlags._selectedSessionPath = selected.path; } -// `sf headless` — run auto-mode without TUI +// `sf headless` — run autonomous mode without TUI if (cliFlags.messages[0] === "headless") { await ensureRtkBootstrap(); // Sync bundled resources before headless runs (#3471). Without this, @@ -566,10 +563,12 @@ if (cliFlags.messages[0] === "headless") { /** * Run a headless command by invoking the headless entrypoint with a synthetic - * argv. Shared by the `auto` shorthand (#2732) and the auto-piped-stdout + * argv. Shared by the `autonomous` shorthand (#2732) and the piped-stdout * redirect so they use the same bootstrap + dynamic-import dance. */ -async function runHeadlessFromAuto(headlessArgs: string[]): Promise { +async function runHeadlessFromAutonomous( + headlessArgs: string[], +): Promise { await ensureRtkBootstrap(); const { runHeadless, parseHeadlessArgs } = await import("./headless.js"); const argv = [process.argv[0], process.argv[1], "headless", ...headlessArgs]; @@ -577,15 +576,14 @@ async function runHeadlessFromAuto(headlessArgs: string[]): Promise { process.exit(0); } -// `sf autonomous [args...]` / `sf auto [args...]` — shorthand for headless -// autonomous mode (#2732). Without this, the command falls through to the TUI -// when stdin/stdout are piped (non-TTY environments). +// `sf autonomous [args...]` — shorthand for headless autonomous mode (#2732). +// The legacy `sf auto` spelling is still accepted for compatibility, but all +// generated prompts use `/sf autonomous`. if (cliFlags.messages[0] === "auto" || cliFlags.messages[0] === "autonomous") { - const headlessArgs = - cliFlags.messages[0] === "autonomous" - ? ["auto", ...cliFlags.messages.slice(1)] - : cliFlags.messages; - await runHeadlessFromAuto(headlessArgs); + await runHeadlessFromAutonomous([ + "autonomous", + ...cliFlags.messages.slice(1), + ]); } // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems @@ -792,28 +790,6 @@ if (isPrintMode) { process.exit(0); } - if (mode === "mcp") { - printStartupTimings(); - const { startMcpServer } = await import("./mcp-server.js"); - - // Activate every registered tool before starting the MCP transport. - // `session.agent.state.tools` is the *active* subset, not the full - // registry — if we expose only the active set, extension-registered - // tools (sf workflow, browser-tools, mac-tools, search-the-web, …) - // are invisible to MCP clients. Flipping the active set to every - // known tool name makes `state.tools` mirror the full registry for - // this MCP session, which is what an external client expects. - const allToolNames = session.getAllTools().map((t) => t.name); - session.setActiveToolsByName(allToolNames); - - await startMcpServer({ - tools: session.agent.state.tools ?? [], - version: process.env.SF_VERSION || "0.0.0", - }); - // MCP server runs until the transport closes; keep alive - await new Promise(() => {}); - } - printStartupTimings(); await runPrintMode(session, { mode: mode as "text" | "json", @@ -884,8 +860,8 @@ if (!cliFlags.worktree && !isPrintMode) { } // --------------------------------------------------------------------------- -// Auto-redirect: autonomous mode with piped stdout → headless mode (#2732) -// When stdout is not a TTY (e.g. `sf auto | cat`, `sf auto > file`), +// Autonomous redirect: autonomous mode with piped stdout → headless mode (#2732) +// When stdout is not a TTY (e.g. `sf autonomous | cat`), // the TUI cannot render and the process hangs. Redirect to headless mode // which handles non-interactive output gracefully. // --------------------------------------------------------------------------- @@ -896,11 +872,10 @@ if ( process.stderr.write( "[forge] stdout is not a terminal — running autonomous mode in headless mode.\n", ); - const headlessArgs = - cliFlags.messages[0] === "autonomous" - ? ["auto", ...cliFlags.messages.slice(1)] - : cliFlags.messages; - await runHeadlessFromAuto(headlessArgs); + await runHeadlessFromAutonomous([ + "autonomous", + ...cliFlags.messages.slice(1), + ]); } // --------------------------------------------------------------------------- diff --git a/src/headless-context.ts b/src/headless-context.ts index e9870c6ca..9c3ff6fa3 100644 --- a/src/headless-context.ts +++ b/src/headless-context.ts @@ -168,7 +168,7 @@ export function buildAutoBootstrapContext(basePath: string): string { const chunks: string[] = [ "# Autonomous Repo Bootstrap", "", - "SF headless auto found no milestones. Use the repository files below as the seed context.", + "SF headless autonomous found no milestones. Use the repository files below as the seed context.", "Research every relevant markdown document and every source file path before creating the initial milestone plan.", "Use tool-based repository inspection for source contents; do not assume the seed excerpt is complete.", "Extract the project purpose, vision, architecture, constraints, current TODOs, risks, eval/gate ideas, and implementation backlog.", @@ -194,7 +194,7 @@ export function buildAutoBootstrapContext(basePath: string): string { if (content.length > AUTO_BOOTSTRAP_MAX_FILE_BYTES) { content = content.slice(0, AUTO_BOOTSTRAP_MAX_FILE_BYTES) + - "\n\n[truncated by SF headless auto bootstrap]\n"; + "\n\n[truncated by SF headless autonomous bootstrap]\n"; } const relPath = relative(basePath, filePath); diff --git a/src/headless-events.ts b/src/headless-events.ts index 1c48dfee2..152e696d9 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -109,7 +109,7 @@ export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000; * on legitimate slow LLM thinking or chained tool calls, but short enough * to recover from a real deadlock within a reasonable bound. * - * Symptom from the old 15s timeout: sf headless auto would dispatch a task, + * Symptom from the old 15s timeout: sf headless autonomous would dispatch a task, * the LLM would make 1-2 tool calls, pause to reason, exceed 15s of "no * events", and headless would declare "Status: complete" — exiting at ~35s * with the task barely started. diff --git a/src/headless.ts b/src/headless.ts index eedeb9e5e..dd3303ad1 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -116,7 +116,7 @@ export interface HeadlessOptions { commandArgs: string[]; context?: string; // file path or '-' for stdin contextText?: string; // inline text - auto?: boolean; // chain into auto-mode after milestone creation + auto?: boolean; // chain into autonomous mode after milestone creation verbose?: boolean; // show tool calls in output maxRestarts?: number; // auto-restart on crash (default 3, 0 to disable) supervised?: boolean; // supervised mode: forward interactive requests to orchestrator @@ -294,7 +294,7 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { timeout: 300_000, json: false, outputFormat: "text", - command: "auto", + command: "autonomous", commandExplicit: false, commandArgs: [], }; @@ -382,8 +382,8 @@ export function parseHeadlessArgs(argv: string[]): HeadlessOptions { options.bare = true; } } else if (!commandSeen) { - if (arg === "autonomous") { - options.command = "auto"; + if (arg === "autonomous" || arg === "auto") { + options.command = "autonomous"; options.auto = true; // autonomous subcommand implies --auto } else { options.command = arg; @@ -488,7 +488,7 @@ async function runHeadlessOnce( ): Promise<{ exitCode: number; interrupted: boolean }> { let interrupted = false; const startTime = Date.now(); - if (options.command === "auto" && !options.resumeSession) { + if (options.command === "autonomous" && !options.resumeSession) { bootstrapProject(process.cwd()); if (!hasMilestones(process.cwd())) { if (!options.json) { @@ -512,11 +512,11 @@ async function runHeadlessOnce( // auto-mode sessions are long-running (minutes to hours) with their own internal // per-unit timeout via auto-supervisor. Disable the overall timeout unless the // user explicitly set --timeout. - const isAutoMode = options.command === "auto"; + const isAutoMode = options.command === "autonomous"; // discuss and plan are multi-turn: they involve multiple question rounds, // codebase scanning, and artifact writing before the workflow completes (#3547). const isMultiTurnCommand = - options.command === "auto" || + options.command === "autonomous" || options.command === "next" || options.command === "discuss" || options.command === "plan"; @@ -524,7 +524,7 @@ async function runHeadlessOnce( // Auto-mode defaults to supervised: wait for user input instead of exiting on questions // This is the desired behavior - auto should wait, not exit on blocked // Can be disabled via --no-supervised or preferences.auto_supervisor.supervised_mode: false - if (options.command === "auto" && options.supervised === undefined) { + if (options.command === "autonomous" && options.supervised === undefined) { // Check preferences for default try { const { loadEffectiveSFPreferences } = await import( @@ -676,10 +676,10 @@ async function runHeadlessOnce( "[headless] Re-linked .sf to existing external project state\n", ); } - } else if (options.command === "auto" && options.commandExplicit) { + } else if (options.command === "autonomous" && options.commandExplicit) { if (!options.json) { process.stderr.write( - "[headless] No .sf/ project state found; initializing for auto mode...\n", + "[headless] No .sf/ project state found; initializing for autonomous mode...\n", ); } bootstrapProject(process.cwd()); diff --git a/src/help-text.ts b/src/help-text.ts index df8846bff..19a142b9b 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -187,7 +187,6 @@ const SUBCOMMAND_HELP: Record = { "", "Commands:", " autonomous Run all queued product units continuously (default)", - " auto Alias for autonomous", " next Run one unit", " status Show progress dashboard", " new-milestone Create a milestone from a specification document", @@ -232,7 +231,7 @@ export function printHelp(version: string): void { process.stdout.write("Usage: sf [options] [message...]\n\n"); process.stdout.write("Options:\n"); process.stdout.write( - " --mode Output mode (default: interactive)\n", + " --mode Output mode (default: interactive)\n", ); process.stdout.write(" --print, -p Single-shot print mode\n"); process.stdout.write( @@ -288,9 +287,8 @@ export function printHelp(version: string): void { process.stdout.write( " autonomous [args] Run autonomous mode without TUI (pipeable)\n", ); - process.stdout.write(" auto [args] Alias for autonomous\n"); process.stdout.write( - " headless [cmd] [args] Run /sf commands without TUI (default: auto)\n", + " headless [cmd] [args] Run /sf commands without TUI (default: autonomous)\n", ); process.stdout.write( " graph Manage knowledge graph (build, query, status, diff)\n", diff --git a/src/mcp-server.ts b/src/mcp-server.ts deleted file mode 100644 index 5d7d5eb27..000000000 --- a/src/mcp-server.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Minimal tool interface matching SF's AgentTool shape. - * Avoids a direct dependency on @singularity-forge/pi-agent-core from this compiled module. - */ -export interface McpToolDef { - name: string; - description: string; - parameters: Record; - execute( - toolCallId: string, - params: Record, - signal?: AbortSignal, - onUpdate?: unknown, - ): Promise<{ - content: Array<{ - type: string; - text?: string; - data?: string; - mimeType?: string; - }>; - }>; -} - -// MCP SDK subpath imports use wildcard exports (./*) in @modelcontextprotocol/sdk's -// package.json export map. The wildcard maps "./foo" → "./dist/cjs/foo" (no .js -// suffix), so bare subpath specifiers like `${MCP_PKG}/server/stdio` resolve to -// a non-existent file. Historically the workaround (#3603) used createRequire so -// the CJS resolver could auto-append `.js`; that no longer works with current -// Node + SDK releases (#3914) — `_require.resolve` also fails with -// "Cannot find module .../dist/cjs/server/stdio". -// -// The reliable convention (matching packages/mcp-server/{server,cli}.ts) is to -// write the `.js` suffix explicitly on every wildcard subpath. Specifiers are -// built via a template string so TypeScript's NodeNext resolver treats them as -// `any` and skips static checking. -const MCP_PKG = "@modelcontextprotocol/sdk"; - -/** - * Starts a native MCP (Model Context Protocol) server over stdin/stdout. - * - * This enables SF's tools (read, write, edit, bash, grep, glob, ls, etc.) - * to be used by external AI clients such as Claude Desktop, VS Code Copilot, - * and any MCP-compatible host. - * - * The server registers all tools from the agent session's tool registry and - * maps MCP tools/list and tools/call requests to SF tool definitions and - * execution, respectively. - * - * All MCP SDK imports are dynamic to avoid subpath export resolution issues - * with TypeScript's NodeNext module resolution. - */ -export async function startMcpServer(options: { - tools: McpToolDef[]; - version?: string; -}): Promise { - const { tools, version = "0.0.0" } = options; - - const serverMod = await import(`${MCP_PKG}/server/index.js`); - const stdioMod = await import(`${MCP_PKG}/server/stdio.js`); - const typesMod = await import(`${MCP_PKG}/types.js`); - - const Server = serverMod.Server; - const StdioServerTransport = stdioMod.StdioServerTransport; - const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod; - - // Build a lookup map for fast tool resolution on calls - const toolMap = new Map(); - for (const tool of tools) { - toolMap.set(tool.name, tool); - } - - const server = new Server( - { name: "sf", version }, - { capabilities: { tools: {} } }, - ); - - // tools/list — return every registered SF tool with its JSON Schema parameters - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map((t: McpToolDef) => ({ - name: t.name, - description: t.description, - inputSchema: t.parameters, - })), - })); - - // tools/call — execute the requested tool and return content blocks. - // - // The MCP SDK passes an `extra` argument to request handlers that includes - // an AbortSignal scoped to the RPC request (cancelled when the client - // cancels the tool call or the transport closes). Threading it into - // AgentTool.execute ensures long-running tools (Bash, WebFetch, grep on - // huge trees) actually stop when the client gives up on the result. - server.setRequestHandler( - CallToolRequestSchema, - async (request: any, extra: any) => { - const { name, arguments: args } = request.params; - const tool = toolMap.get(name); - if (!tool) { - return { - isError: true, - content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], - }; - } - - const signal: AbortSignal | undefined = extra?.signal; - - try { - const result = await tool.execute( - `mcp-${Date.now()}`, - args ?? {}, - signal, - undefined, // onUpdate not yet wired — progress notifications require a progressToken round-trip - ); - - // Convert AgentToolResult content blocks to MCP content format. - // text and image pass through; any other shape is serialized as text - // so the client sees the payload rather than an empty response. - const content = result.content.map((block: any) => { - if (block.type === "text") - return { type: "text" as const, text: block.text ?? "" }; - if (block.type === "image") { - return { - type: "image" as const, - data: block.data ?? "", - mimeType: block.mimeType ?? "image/png", - }; - } - // Preserve unknown block types (resource, resource_link, audio, ...) - // by stringifying into a text block so clients see the payload. - return { type: "text" as const, text: JSON.stringify(block) }; - }); - return { content }; - } catch (err: unknown) { - // AbortError from a cancelled tool surfaces as a normal error — MCP - // clients interpret `isError: true` as a failed call, which is the - // correct behaviour for a cancelled request. - const message = err instanceof Error ? err.message : String(err); - return { - isError: true, - content: [{ type: "text" as const, text: message }], - }; - } - }, - ); - - // Connect to stdin/stdout transport - const transport = new StdioServerTransport(); - await server.connect(transport); - process.stderr.write(`[forge] MCP server started (v${version})\n`); -} diff --git a/src/resources/extensions/claude-code-cli/partial-builder.js b/src/resources/extensions/claude-code-cli/partial-builder.js index 24f5194ad..8822cf690 100644 --- a/src/resources/extensions/claude-code-cli/partial-builder.js +++ b/src/resources/extensions/claude-code-cli/partial-builder.js @@ -9,11 +9,11 @@ import { hasXmlParameterTags, repairToolJson } from "@singularity-forge/pi-ai"; // MCP tool name parsing // --------------------------------------------------------------------------- /** - * Split a Claude Code MCP tool name (`mcp____`) into its parts. + * Split an adapter-surfaced MCP tool name (`mcp____`) into its parts. * Returns null for non-prefixed names so callers can fall through unchanged. * - * Server names may contain hyphens (`sf-workflow`); the SDK uses the literal - * `__` delimiter between the server name and the tool name. + * Server names may contain hyphens; the SDK uses the literal `__` delimiter + * between the server name and the tool name. */ export function parseMcpToolName(name) { if (!name.startsWith("mcp__")) return null; @@ -23,7 +23,7 @@ export function parseMcpToolName(name) { return { server: rest.slice(0, delim), tool: rest.slice(delim + 2) }; } /** - * Build a SF ToolCall block from a Claude Code SDK tool_use block, stripping + * Build a SF ToolCall block from an adapter SDK tool_use block, stripping * the `mcp____` prefix from the name so registered extension renderers * (which use the unprefixed canonical names) can match. The original server * name is preserved on the block for diagnostics and rendering. diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.js b/src/resources/extensions/claude-code-cli/stream-adapter.js index e7b5499e7..1f141f15a 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.js +++ b/src/resources/extensions/claude-code-cli/stream-adapter.js @@ -11,7 +11,6 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { EventStream } from "@singularity-forge/pi-ai"; -import { buildWorkflowMcpServers } from "../sf/workflow-mcp.js"; import { showInterviewRound } from "../shared/tui.js"; import { mapUsage, @@ -729,7 +728,7 @@ function formatToolInput(toolName, input) { * Follows the same pattern as {@link createClaudeCodeElicitationHandler}: * takes an optional UI context and returns the callback or undefined. * - * When UI is unavailable (headless / auto-mode sub-agents), returns a handler + * When UI is unavailable (headless / autonomous sub-agents), returns a handler * that always approves — replacing the old SF_AUTO_MODE → bypassPermissions * workaround. */ @@ -965,8 +964,8 @@ export function makeAbortedMessage(model, lastTextContent) { * Resolve the Claude Code permission mode for the current run. * * SF subagents run underneath a host Claude Code session the user has - * already consented to, and their work (edits, shell inspection, MCP calls) - * spans the full workflow toolset. Defaulting the inner SDK to + * already consented to, and their work spans the full direct runtime toolset. + * Defaulting the inner SDK to * `bypassPermissions` avoids per-tool approval prompts that offer no * meaningful safety beyond what the host session and the subagent prompts * already enforce. `SF_CLAUDE_CODE_PERMISSION_MODE` lets security-conscious @@ -1047,14 +1046,11 @@ export function buildSdkOptions( const reasoning = requestedReasoning === "auto" ? undefined : requestedReasoning; const autoReasoning = requestedReasoning === "auto"; - const mcpServers = buildWorkflowMcpServers(); const permissionMode = overrides?.permissionMode ?? "bypassPermissions"; const disallowedTools = ["AskUserQuestion"]; - // Pre-authorize the safe built-ins and every registered workflow MCP - // server's tools. `acceptEdits` mode (the interactive default) only - // auto-approves file edits — Read/Glob/Grep, basic shell inspection, and - // every `mcp__sf-workflow__*` call still surface as "This command - // requires approval" and block SF actions (#4099). + // Pre-authorize the safe built-ins. `acceptEdits` mode (the interactive + // default) only auto-approves file edits, so read-only inspection still + // needs explicit allow-listing for autonomous SF actions (#4099). const allowedTools = [ "Read", "Write", @@ -1063,9 +1059,6 @@ export function buildSdkOptions( "Grep", "Bash(ls:*)", "Bash(pwd)", - ...(mcpServers - ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) - : []), ]; const supportsAdaptive = modelSupportsAdaptiveThinking(modelId); const effort = @@ -1093,7 +1086,6 @@ export function buildSdkOptions( systemPrompt: { type: "preset", preset: "claude_code" }, disallowedTools, ...(allowedTools.length > 0 ? { allowedTools } : {}), - ...(mcpServers ? { mcpServers } : {}), betas: modelId.includes("sonnet") || modelId.includes("opus-4-7") || @@ -1260,7 +1252,7 @@ async function pumpSdkMessages(model, context, options, stream) { const permissionMode = await resolveClaudePermissionMode(); const uiContext = options?.extensionUIContext; const canUseToolHandler = createClaudeCodeCanUseToolHandler(uiContext); - // When no UI is available (headless / auto-mode), auto-approve all + // When no UI is available (headless / autonomous), auto-approve all // tool requests. This replaces the old bypassPermissions workaround. const canUseToolFallback = canUseToolHandler ?? @@ -1493,7 +1485,7 @@ async function pumpSdkMessages(model, context, options, stream) { } // Generator exhaustion without a terminal result is a stream interruption, // not a successful completion. Emitting an error lets SF classify it as a - // transient provider failure instead of advancing auto-mode state. + // transient provider failure instead of advancing autonomous state. const fallback = makeStreamExhaustedErrorMessage(modelId, lastTextContent); stream.push({ type: "error", reason: "error", error: fallback }); } catch (err) { diff --git a/src/resources/extensions/sf-tui/emoji.js b/src/resources/extensions/sf-tui/emoji.js index 8f608b12b..d33c6967d 100644 --- a/src/resources/extensions/sf-tui/emoji.js +++ b/src/resources/extensions/sf-tui/emoji.js @@ -45,7 +45,7 @@ export function registerSessionEmoji(pi) { }; registerCommands(pi, state); // Gate the session-lifecycle work on having a real TUI. Headless mode - // (sf headless auto, --print, CI) has no footer to render into, and the + // (sf headless autonomous, --print, CI) has no footer to render into, and the // AI auto-assign path would spend tokens choosing an emoji nothing sees. pi.on("session_start", (_, ctx) => { if (!ctx.hasUI) return; diff --git a/src/resources/extensions/sf/auto-bootstrap-context.js b/src/resources/extensions/sf/auto-bootstrap-context.js index e077526a7..875911d48 100644 --- a/src/resources/extensions/sf/auto-bootstrap-context.js +++ b/src/resources/extensions/sf/auto-bootstrap-context.js @@ -94,7 +94,7 @@ export function buildAutoBootstrapContext(basePath) { const chunks = [ "# Autonomous Repo Bootstrap", "", - "SF headless auto found no milestones. Use the repository files below as the seed context.", + "SF headless autonomous found no milestones. Use the repository files below as the seed context.", "Research every relevant markdown document and every source file path before creating the initial milestone plan.", "Use tool-based repository inspection for source contents; do not assume the seed excerpt is complete.", "Extract the project purpose, vision, architecture, constraints, current TODOs, risks, eval/gate ideas, and implementation backlog.", @@ -119,7 +119,7 @@ export function buildAutoBootstrapContext(basePath) { if (content.length > AUTO_BOOTSTRAP_MAX_FILE_BYTES) { content = content.slice(0, AUTO_BOOTSTRAP_MAX_FILE_BYTES) + - "\n\n[truncated by SF headless auto bootstrap]\n"; + "\n\n[truncated by SF headless autonomous bootstrap]\n"; } const relPath = relative(basePath, filePath); const block = `\n\n## ${relPath}\n\n${content.trim()}\n`; @@ -138,7 +138,7 @@ export function buildAutoBootstrapContext(basePath) { if (block.length > AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) { block = block.slice(0, AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) + - "\n\n[truncated by SF headless auto bootstrap]\n"; + "\n\n[truncated by SF headless autonomous bootstrap]\n"; } if (used + block.length <= AUTO_BOOTSTRAP_MAX_BYTES) { chunks.push(block); diff --git a/src/resources/extensions/sf/auto-dispatch.js b/src/resources/extensions/sf/auto-dispatch.js index 4db4ff5b1..7159fa4a7 100644 --- a/src/resources/extensions/sf/auto-dispatch.js +++ b/src/resources/extensions/sf/auto-dispatch.js @@ -1,5 +1,5 @@ /** - * Auto-mode Dispatch Table — declarative phase → unit mapping. + * Autonomous mode Dispatch Table — declarative phase → unit mapping. * * Each rule maps a SF state to the unit type, unit ID, and prompt builder * that should be dispatched. Rules are evaluated in order; the first match wins. @@ -105,7 +105,7 @@ function missingSliceStop(mid, phase) { function canonicalPlanStop(mid, plan) { return { action: "stop", - reason: `${mid}: canonical milestone plan unavailable (${plan.source}): ${plan.reason} Run /sf doctor or regenerate structured roadmap state before dispatching auto-mode work.`, + reason: `${mid}: canonical milestone plan unavailable (${plan.source}): ${plan.reason} Run /sf doctor or regenerate structured roadmap state before dispatching autonomous mode work.`, level: "error", }; } @@ -381,7 +381,7 @@ function buildValidationAttentionRemediationPrompt( const validationRel = `.sf/milestones/${mid}/${mid}-VALIDATION.md`; const escapedValidation = validationContent.replace(/```/g, "``\\`"); const escapedPlan = attentionPlan.replace(/```/g, "``\\`"); - return `You are executing SF auto-mode. + return `You are executing SF autonomous mode. ## UNIT: Resolve Validation Attention for ${mid} ("${midTitle}") @@ -447,7 +447,7 @@ export const DISPATCH_RULES = [ }, { // ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation handling. - // Auto-mode is autonomous, so by default we accept the agent's + // Autonomous mode is autonomous, so by default we accept the agent's // recommendation and continue — the user can review/override later via // `/sf escalate list --all`. Set `phases.escalation_auto_accept: false` // to keep gsd-2's pause-and-ask behavior. @@ -469,8 +469,8 @@ export const DISPATCH_RULES = [ state.activeSlice.id, state.activeTask.id, "accept", - "auto-mode: accepted agent recommendation; user can override via /sf escalate", - "auto-mode", + "autonomous mode: accepted agent recommendation; user can override via /sf escalate", + "autonomous mode", ); if (result.status === "resolved") { // Flags cleared; let the next dispatch cycle re-read state and @@ -536,7 +536,7 @@ export const DISPATCH_RULES = [ "You are facilitating the **initial roadmap meeting** for milestone " + mid + ".\n\n" + - "You are running in SF auto-mode. Do not call `ask_user_questions`, " + + "You are running in SF autonomous mode. Do not call `ask_user_questions`, " + "do not wait for a human reply, and do not end with open questions. " + "Use existing project artifacts as the user's durable input. If `" + mid + @@ -891,7 +891,7 @@ export const DISPATCH_RULES = [ if (!contradiction) return null; return { action: "stop", - reason: `${mid}: ${contradiction}. Regenerate structured roadmap state before dispatching auto-mode work.`, + reason: `${mid}: ${contradiction}. Regenerate structured roadmap state before dispatching autonomous mode work.`, level: "error", }; }, @@ -1663,7 +1663,7 @@ export async function resolveDispatch(ctx) { if (ctx.pipelineVariant === undefined) { ctx.pipelineVariant = await getMilestonePipelineVariant(ctx.mid); } - // Delegate to registry when available. Callers that run outside auto-mode + // Delegate to registry when available. Callers that run outside autonomous mode // (e.g. `sf headless query`, `sf headless status`) never initialize the // registry — falling through to inline rules is the intended behavior, // not an error, so we silent-probe instead of warning on every call. diff --git a/src/resources/extensions/sf/auto-model-selection.js b/src/resources/extensions/sf/auto-model-selection.js index cd3ab34a7..277aebf5d 100644 --- a/src/resources/extensions/sf/auto-model-selection.js +++ b/src/resources/extensions/sf/auto-model-selection.js @@ -69,7 +69,7 @@ export class ModelPolicyDispatchBlockedError extends Error { // LIFECYCLE: the baseline is tied to a single auto session, NOT to the // lifetime of the `pi` instance (which can outlive many auto runs and have // the user mutate tools between them). `clearToolBaseline` MUST be called -// at auto start AND auto stop so that a second `/sf auto` run on the same +// at auto start AND auto stop so that a second `/sf autonomous` run on the same // `pi` does not silently restore a stale snapshot from the prior run and // undo any tool changes the user made between sessions. const TOOL_BASELINE = new WeakMap(); @@ -612,7 +612,7 @@ export async function selectAndApplyModel( } // Skip models the provider has previously rejected for this account // (issue #4513). The block is persisted in .sf/runtime/blocked-models.json - // so it survives /sf auto restarts — without this, the same dead model + // so it survives /sf autonomous restarts — without this, the same dead model // gets reselected after every restart. if (isModelBlocked(basePath, model.provider, model.id)) { ctx.ui.notify( diff --git a/src/resources/extensions/sf/auto-start.js b/src/resources/extensions/sf/auto-start.js index 9c2a86fb8..db90f14f7 100644 --- a/src/resources/extensions/sf/auto-start.js +++ b/src/resources/extensions/sf/auto-start.js @@ -464,12 +464,6 @@ export async function bootstrapAutoSession( ); } } - { - const { prepareWorkflowMcpForProject } = await import( - "./workflow-mcp-auto-prep.js" - ); - prepareWorkflowMcpForProject(ctx, base); - } // Initialize GitServiceImpl s.gitService = new GitServiceImpl( s.basePath, diff --git a/src/resources/extensions/sf/auto.js b/src/resources/extensions/sf/auto.js index f23816b32..f475ef87d 100644 --- a/src/resources/extensions/sf/auto.js +++ b/src/resources/extensions/sf/auto.js @@ -596,7 +596,7 @@ export function stopAutoRemote(projectRoot) { * Check if a remote auto-mode session is running (from a different process). * Reads the crash lock, checks PID liveness, and returns session details. * Used by the guard in commands.ts to prevent bare /sf, /sf next, and - * /sf auto from stealing the session lock. + * /sf autonomous from stealing the session lock. */ export function checkRemoteAutoSession(projectRoot) { const lock = readCrashLock(projectRoot); @@ -1111,7 +1111,7 @@ export async function stopAuto(ctx, pi, reason) { `auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`, ); } - // Drop the active-tool baseline so a subsequent /sf auto run on the + // Drop the active-tool baseline so a subsequent /sf autonomous run on the // same `pi` instance recaptures from the live tool set rather than // restoring this session's snapshot and silently undoing any tool // changes the user made between sessions (#4959 / CodeRabbit). @@ -1122,7 +1122,7 @@ export async function stopAuto(ctx, pi, reason) { } /** * Pause auto-mode without destroying state. Context is preserved. - * The user can interact with the agent, then `/sf auto` resumes + * The user can interact with the agent, then `/sf autonomous` resumes * from disk state. Called when the user presses Escape during auto-mode. */ export async function pauseAuto(ctx, _pi, _errorContext) { diff --git a/src/resources/extensions/sf/auto/phases.js b/src/resources/extensions/sf/auto/phases.js index 2d83b4874..9931165d5 100644 --- a/src/resources/extensions/sf/auto/phases.js +++ b/src/resources/extensions/sf/auto/phases.js @@ -1304,7 +1304,7 @@ export async function runGuards(ic, mid, unitType, unitId, sliceId) { debugLog("guards", { phase: "stop-guard-error", error: String(e) }); return { action: "break", reason: "stop-guard-error" }; } - // Production mutation guard — headless auto must not enqueue live failover + // Production mutation guard — headless autonomous must not enqueue live failover // commands without a human-provided safe target and cleanup plan. try { if (isDbAvailable()) { @@ -1365,7 +1365,7 @@ export async function runGuards(ic, mid, unitType, unitId, sliceId) { const msg = `Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` + `${template.created ? "Created" : "Reusing"} approval gate at ${template.path}. ` + - `Fill it with an explicit safe server/VM target, cleanup/rollback path, and human or LLM approval, then rerun sf headless auto.${reasons}`; + `Fill it with an explicit safe server/VM target, cleanup/rollback path, and human or LLM approval, then rerun sf headless autonomous.${reasons}`; ctx.ui.notify(msg, "error"); deps.sendDesktopNotification( "SF", diff --git a/src/resources/extensions/sf/auto/session.js b/src/resources/extensions/sf/auto/session.js index 5db97e417..587c4f3b1 100644 --- a/src/resources/extensions/sf/auto/session.js +++ b/src/resources/extensions/sf/auto/session.js @@ -48,7 +48,7 @@ export class AutoSession { fullAutonomy = false; /** * When false, the agent is forbidden from calling ask_user_questions. - * Step mode and `/sf auto` set this true; `/sf autonomous` sets it false. + * Step mode sets this true; `/sf autonomous` sets it false. */ canAskUser = true; verbose = false; diff --git a/src/resources/extensions/sf/blocked-models.js b/src/resources/extensions/sf/blocked-models.js index 746eec220..f1ef2ea34 100644 --- a/src/resources/extensions/sf/blocked-models.js +++ b/src/resources/extensions/sf/blocked-models.js @@ -1,7 +1,7 @@ // SF — Persistent per-project blocklist of provider/model pairs that the // provider has rejected at request time for account entitlement reasons. // -// Lives at `.sf/runtime/blocked-models.json` so the block survives /sf auto +// Lives at `.sf/runtime/blocked-models.json` so the block survives /sf autonomous // restarts. Auto-mode model selection skips blocked entries; agent-end // recovery adds entries when a runtime rejection is classified as // `unsupported-model`. See issue #4513. diff --git a/src/resources/extensions/sf/bootstrap/agent-end-recovery.js b/src/resources/extensions/sf/bootstrap/agent-end-recovery.js index 5b6a68c6c..ab79616b5 100644 --- a/src/resources/extensions/sf/bootstrap/agent-end-recovery.js +++ b/src/resources/extensions/sf/bootstrap/agent-end-recovery.js @@ -195,7 +195,7 @@ export async function handleAgentEnd(pi, event, ctx) { } // ── 1c. Unsupported-model: provider rejected this model for the current // account/plan at request time (#4513). Persist a block so the - // same dead model isn't reselected on the next /sf auto restart, + // same dead model isn't reselected on the next /sf autonomous restart, // then try a fallback before pausing. if (cls.kind === "unsupported-model") { const rejectedProvider = currentRoute?.provider; diff --git a/src/resources/extensions/sf/bootstrap/db-tools.js b/src/resources/extensions/sf/bootstrap/db-tools.js index c712cf2eb..cab515d23 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.js +++ b/src/resources/extensions/sf/bootstrap/db-tools.js @@ -1769,7 +1769,7 @@ export function registerDbTools(pi) { updateSliceStatus(params.milestoneId, params.sliceId, "skipped"); invalidateStateCache(); // Rebuild STATE.md so it reflects the skip immediately (#3477). - // Without this, /sf auto reads stale STATE.md and resumes the skipped slice. + // Without this, /sf autonomous reads stale STATE.md and resumes the skipped slice. try { const basePath = process.cwd(); const { rebuildState } = await import("../doctor.js"); diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index aa6b19cfb..ebcbc8c4f 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -128,10 +128,6 @@ export function registerHooks(pi, ecosystemHandlers = []) { resetToolCallLoopGuard(); resetAskUserQuestionsCache(); await syncServiceTierStatus(ctx); - const { prepareWorkflowMcpForProject } = await import( - "../workflow-mcp-auto-prep.js" - ); - prepareWorkflowMcpForProject(ctx, process.cwd()); await initializeLearningRuntime(); // Apply show_token_cost preference (#1515) try { @@ -240,7 +236,7 @@ export function registerHooks(pi, ecosystemHandlers = []) { ); } // Forge-only: high/critical entries are queued as hidden follow-up repair - // work on startup, even outside /sf auto. The drain helper owns claim TTL + // work on startup, even outside /sf autonomous. The drain helper owns claim TTL // and delivery failure retry, so this is safe to call opportunistically. const highBlocked = triage.stillBlocked.filter( (e) => e.severity === "high" || e.severity === "critical", @@ -327,10 +323,6 @@ export function registerHooks(pi, ecosystemHandlers = []) { resetAskUserQuestionsCache(); clearDiscussionFlowState(); await syncServiceTierStatus(ctx); - const { prepareWorkflowMcpForProject } = await import( - "../workflow-mcp-auto-prep.js" - ); - prepareWorkflowMcpForProject(ctx, process.cwd()); await initializeLearningRuntime(); loadToolApiKeys(); }); diff --git a/src/resources/extensions/sf/commands-bootstrap.js b/src/resources/extensions/sf/commands-bootstrap.js index 25786521e..2c3e17f3c 100644 --- a/src/resources/extensions/sf/commands-bootstrap.js +++ b/src/resources/extensions/sf/commands-bootstrap.js @@ -24,7 +24,7 @@ const TOP_LEVEL_SUBCOMMANDS = [ { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, { cmd: "history", desc: "View execution history" }, { cmd: "undo", desc: "Revert last completed unit" }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, + { cmd: "skip", desc: "Prevent a unit from autonomous dispatch" }, { cmd: "export", desc: "Export milestone or slice results" }, { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, @@ -40,7 +40,7 @@ const TOP_LEVEL_SUBCOMMANDS = [ { cmd: "init", desc: "Project init wizard" }, { cmd: "setup", desc: "Global setup status and configuration" }, { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, + { cmd: "remote", desc: "Control remote autonomous mode" }, { cmd: "steer", desc: "Hard-steer plan documents during execution" }, { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, { cmd: "knowledge", desc: "Add persistent project knowledge" }, @@ -80,7 +80,7 @@ function getSfArgumentCompletions(prefix) { return filterStartsWith(parts[0] ?? "", TOP_LEVEL_SUBCOMMANDS); } const partial = parts[1] ?? ""; - if ((parts[0] === "auto" || parts[0] === "autonomous") && parts.length <= 2) { + if (parts[0] === "autonomous" && parts.length <= 2) { return filterStartsWith( partial, [ diff --git a/src/resources/extensions/sf/commands-mcp-status.js b/src/resources/extensions/sf/commands-mcp-status.js index 656ff39c0..4f90f68f5 100644 --- a/src/resources/extensions/sf/commands-mcp-status.js +++ b/src/resources/extensions/sf/commands-mcp-status.js @@ -7,27 +7,10 @@ * /sf mcp — Overview of all servers (alias: /sf mcp status) * /sf mcp status — Same as bare /sf mcp * /sf mcp check — Detailed status for a specific server - * /sf mcp init [dir] — Write project-local SF workflow MCP config */ import { existsSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { ensureProjectWorkflowMcpConfig } from "./mcp-project-config.js"; -export function formatMcpInitResult(status, configPath, targetPath) { - const summary = - status === "created" - ? "Created project MCP config." - : status === "updated" - ? "Updated project MCP config." - : "Project MCP config is already up to date."; - return [ - summary, - "", - `Project: ${targetPath}`, - `Config: ${configPath}`, - "", - "Claude Code can now load the SF workflow MCP server from this folder.", - ].join("\n"); -} +import { join } from "node:path"; + function readMcpConfigs() { const servers = []; const seen = new Set(); @@ -70,8 +53,8 @@ export function formatMcpStatusReport(servers) { return [ "No MCP servers configured.", "", - "Add servers to .mcp.json or .sf/mcp.json to enable MCP integrations.", - "Tip: run /sf mcp init . to write the local SF workflow MCP config.", + "Add external tool servers to .mcp.json or .sf/mcp.json to enable MCP integrations.", + "SF does not expose its own workflow as an MCP server.", "See: https://modelcontextprotocol.io/quickstart", ].join("\n"); } @@ -123,24 +106,13 @@ export async function handleMcpStatus(args, ctx) { const trimmed = args.trim(); const lowered = trimmed.toLowerCase(); const configs = readMcpConfigs(); - // /sf mcp init [dir] if (!lowered || lowered === "status") { // handled below } else if (lowered === "init" || lowered.startsWith("init ")) { - const rawPath = trimmed.slice("init".length).trim(); - const targetPath = resolve(rawPath || "."); - try { - const result = ensureProjectWorkflowMcpConfig(targetPath); - ctx.ui.notify( - formatMcpInitResult(result.status, result.configPath, targetPath), - "info", - ); - } catch (err) { - ctx.ui.notify( - `Failed to prepare MCP config for ${targetPath}: ${err instanceof Error ? err.message : String(err)}`, - "error", - ); - } + ctx.ui.notify( + "SF workflow MCP exposure is disabled. Configure only external MCP tool servers in .mcp.json or .sf/mcp.json.", + "warning", + ); return; } // /sf mcp check @@ -218,10 +190,9 @@ export async function handleMcpStatus(args, ctx) { } // Unknown subcommand ctx.ui.notify( - "Usage: /sf mcp [status|check |init [dir]]\n\n" + + "Usage: /sf mcp [status|check ]\n\n" + " status Show all MCP server statuses (default)\n" + - " check Detailed status for a specific server\n" + - " init [dir] Write .mcp.json for the local SF workflow MCP server", + " check Detailed status for a specific server", "warning", ); } diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index ce78b9344..f5ea31ab8 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -66,7 +66,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [ cmd: "rate", desc: "Rate last unit's model tier (over/ok/under) — improves adaptive routing", }, - { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, + { cmd: "skip", desc: "Prevent a unit from autonomous dispatch" }, { cmd: "export", desc: "Export milestone/slice results" }, { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, { @@ -104,7 +104,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [ }, { cmd: "setup", desc: "Global setup status and configuration" }, { cmd: "migrate", desc: "Migrate a v1 .planning directory to .sf format" }, - { cmd: "remote", desc: "Control remote auto-mode" }, + { cmd: "remote", desc: "Control remote autonomous mode" }, { cmd: "steer", desc: "Hard-steer plan documents during execution" }, { cmd: "inspect", desc: "Show SQLite DB diagnostics" }, { @@ -142,7 +142,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [ { cmd: "fast", desc: "Toggle OpenAI service tier (on/off/flex/status)" }, { cmd: "mcp", - desc: "MCP server status, connectivity, and local config bootstrap (status, check, init)", + desc: "External MCP server status and connectivity (status, check)", }, { cmd: "rethink", @@ -202,18 +202,6 @@ const NESTED_COMPLETIONS = { { cmd: "--verbose", desc: "Show detailed execution output" }, { cmd: "--debug", desc: "Enable debug logging" }, ], - auto: [ - { - cmd: "full", - desc: "Auto-merge milestones; chain end-to-end without review", - }, - { - cmd: "--full", - desc: "Auto-merge milestones; chain end-to-end without review", - }, - { cmd: "--verbose", desc: "Show detailed execution output" }, - { cmd: "--debug", desc: "Enable debug logging" }, - ], next: [ { cmd: "--verbose", desc: "Show detailed step output" }, { cmd: "--dry-run", desc: "Preview next step without executing" }, @@ -347,10 +335,6 @@ const NESTED_COMPLETIONS = { mcp: [ { cmd: "status", desc: "Show all MCP server statuses (default)" }, { cmd: "check", desc: "Detailed status for a specific server" }, - { - cmd: "init", - desc: "Write .mcp.json for the local SF workflow MCP server", - }, ], doctor: [ { cmd: "fix", desc: "Auto-fix detected issues" }, @@ -377,11 +361,11 @@ const NESTED_COMPLETIONS = { ], workflow: [ { cmd: "new", desc: "Create a new workflow definition (via skill)" }, - { cmd: "run", desc: "Create a run and start auto-mode" }, + { cmd: "run", desc: "Create a run and start autonomous mode" }, { cmd: "list", desc: "List workflow runs" }, { cmd: "validate", desc: "Validate a workflow definition YAML" }, - { cmd: "pause", desc: "Pause custom workflow auto-mode" }, - { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, + { cmd: "pause", desc: "Pause custom workflow autonomous mode" }, + { cmd: "resume", desc: "Resume paused custom workflow autonomous mode" }, ], codebase: [ { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, diff --git a/src/resources/extensions/sf/commands/context.js b/src/resources/extensions/sf/commands/context.js index 427497781..bc0061291 100644 --- a/src/resources/extensions/sf/commands/context.js +++ b/src/resources/extensions/sf/commands/context.js @@ -48,14 +48,14 @@ export async function guardRemoteSession(ctx, _pi) { // forever because there is no terminal to answer them. Notify and bail. if (process.env.SF_WEB_BRIDGE_TUI === "1") { ctx.ui.notify( - `Another auto-mode session (PID ${remote.pid}) is running on this project (${unitLabel}). ` + + `Another autonomous mode session (PID ${remote.pid}) is running on this project (${unitLabel}). ` + `Stop it first with /sf stop, or use /sf steer to redirect it.`, "warning", ); return false; } const choice = await showNextAction(ctx, { - title: `Auto-mode is running in another terminal (PID ${remote.pid})`, + title: `Autonomous mode is running in another terminal (PID ${remote.pid})`, summary: [ `Currently executing: ${unitLabel}`, ...(remote.startedAt ? [`Started: ${remote.startedAt}`] : []), @@ -92,7 +92,7 @@ export async function guardRemoteSession(ctx, _pi) { } if (choice === "steer") { ctx.ui.notify( - "Use /sf steer to redirect the running auto-mode session.\n" + + "Use /sf steer to redirect the running autonomous mode session.\n" + "Example: /sf steer Use Postgres instead of SQLite", "info", ); @@ -102,12 +102,12 @@ export async function guardRemoteSession(ctx, _pi) { const result = stopAutoRemote(projectRoot()); if (result.found) { ctx.ui.notify( - `Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, + `Sent stop signal to autonomous mode session (PID ${result.pid}). It will shut down gracefully.`, "info", ); } else if (result.error) { ctx.ui.notify( - `Failed to stop remote auto-mode: ${result.error}`, + `Failed to stop remote autonomous mode: ${result.error}`, "error", ); } else { diff --git a/src/resources/extensions/sf/commands/handlers/auto.js b/src/resources/extensions/sf/commands/handlers/auto.js index 9ce8e72ba..46facf816 100644 --- a/src/resources/extensions/sf/commands/handlers/auto.js +++ b/src/resources/extensions/sf/commands/handlers/auto.js @@ -16,8 +16,9 @@ import { guardRemoteSession, projectRoot } from "../context.js"; /** * Parse --yolo flag and optional file path from the autonomous command string. - * Supports: `/sf autonomous --yolo path/to/file.md`, `/sf auto --yolo path/to/file.md`, - * or `/sf auto -y path/to/file.md`. + * Supports: `/sf autonomous --yolo path/to/file.md` or + * `/sf autonomous -y path/to/file.md`. The legacy `/sf auto` spelling is still + * normalized for compatibility, but is not a distinct user-facing mode. */ function parseYoloFlag(trimmed) { const yoloRe = /(?:--yolo|-y)\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)/; @@ -47,13 +48,14 @@ export function parseMilestoneTarget(input) { return { milestoneId: match[1], rest }; } /** - * Dispatch entry point for the auto-mode command family. + * Dispatch entry point for the autonomous command family. * - * Handles `/sf auto`, `/sf autonomous`, `/sf next`, `/sf stop`, `/sf pause`, and - * their flag variants. Returns `true` when the command was recognised and - * routed (caller stops searching), `false` when the command isn't auto-related. + * Handles `/sf autonomous`, `/sf next`, `/sf stop`, `/sf pause`, and their flag + * variants. Legacy `/sf auto` is accepted as a hidden compatibility spelling. + * Returns `true` when the command was recognised and routed (caller stops + * searching), `false` when the command isn't autonomous-related. * - * Recognised flags on autonomous/auto: + * Recognised flags on autonomous: * - `full` or `--full` — full-autonomy mode (auto-merge + chain milestones) * - `--verbose` — verbose execution output * - `--debug` — enable debug logging via SF_DEBUG @@ -67,15 +69,15 @@ export function parseMilestoneTarget(input) { export async function handleAutoCommand(trimmed, ctx, pi) { const isAutonomousVerb = trimmed === "autonomous" || trimmed.startsWith("autonomous "); - const isAutoVerb = trimmed === "auto" || trimmed.startsWith("auto "); - const isAutonomousFamily = isAutonomousVerb || isAutoVerb; + const isLegacyAutoVerb = trimmed === "auto" || trimmed.startsWith("auto "); + const isAutonomousFamily = isAutonomousVerb || isLegacyAutoVerb; /** - * Route an auto-mode launch through either the headless (in-process) or + * Route an autonomous launch through either the headless (in-process) or * detached (spawned subprocess) entry point depending on `SF_HEADLESS`. * * Headless mode runs the auto loop in the current process (used by CI, - * tests, and `sf headless`); detached mode forks a long-running child so - * the interactive shell stays responsive while auto-mode runs. + * tests, and `sf headless`); detached mode forks a long-running child so the + * interactive shell stays responsive while autonomous mode runs. */ const launchAuto = async (verboseMode, options) => { if (process.env.SF_HEADLESS === "1") { @@ -113,7 +115,7 @@ export async function handleAutoCommand(trimmed, ctx, pi) { return true; } if (isAutonomousFamily) { - const normalized = trimmed.replace(/^(?:auto|autonomous)\b/, "auto"); + const normalized = trimmed.replace(/^(?:auto|autonomous)\b/, "autonomous"); const { yoloSeedFile, rest: afterYolo } = parseYoloFlag(normalized); const { milestoneId, rest: afterMilestone } = parseMilestoneTarget(afterYolo); @@ -124,8 +126,7 @@ export async function handleAutoCommand(trimmed, ctx, pi) { // for human review. Git revert is the safety net. const fullAutonomy = /\bfull\b/.test(afterMilestone) || afterMilestone.includes("--full"); - // `/sf auto` can ask the user when blocked; `/sf autonomous` cannot. - const canAskUser = isAutoVerb; + const canAskUser = false; if (debugMode) enableDebug(projectRoot()); if (!(await guardRemoteSession(ctx, pi))) return true; // Validate the milestone target exists and is not already complete. @@ -151,7 +152,7 @@ export async function handleAutoCommand(trimmed, ctx, pi) { return true; } // Headless path: bootstrap project, dispatch non-interactive discuss, - // then auto-mode starts automatically via checkAutoStartAfterDiscuss + // then autonomous mode starts automatically via checkAutoStartAfterDiscuss // when the LLM says "Milestone X ready." const { showHeadlessMilestoneCreation } = await import( "../../guided-flow.js" @@ -173,16 +174,16 @@ export async function handleAutoCommand(trimmed, ctx, pi) { const result = stopAutoRemote(projectRoot()); if (result.found) { ctx.ui.notify( - `Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, + `Sent stop signal to autonomous mode session (PID ${result.pid}). It will shut down gracefully.`, "info", ); } else if (result.error) { ctx.ui.notify( - `Failed to stop remote auto-mode: ${result.error}`, + `Failed to stop remote autonomous mode: ${result.error}`, "error", ); } else { - ctx.ui.notify("Auto-mode is not running.", "info"); + ctx.ui.notify("Autonomous mode is not running.", "info"); } return true; } @@ -197,7 +198,7 @@ export async function handleAutoCommand(trimmed, ctx, pi) { "info", ); } else { - ctx.ui.notify("Auto-mode is not running.", "info"); + ctx.ui.notify("Autonomous mode is not running.", "info"); } return true; } diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index 3a8e3d172..dd6a1c475 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -79,7 +79,7 @@ export function showHelp(ctx, args = "") { " /sf steer Apply user override to active work", " /sf capture Quick-capture a thought to CAPTURES.md", " /sf triage Classify and route pending captures", - " /sf skip Prevent a unit from auto-mode dispatch", + " /sf skip Prevent a unit from autonomous mode dispatch", " /sf undo Revert last completed unit [--force]", " /sf rethink Conversational project reorganization — reorder, park, discard, add milestones", " /sf park [id] Park a milestone — skip without deleting [reason]", @@ -107,7 +107,7 @@ export function showHelp(ctx, args = "") { " /sf hooks Show post-unit hook configuration", " /sf extensions Manage extensions [list|enable|disable|info]", " /sf fast Toggle OpenAI service tier [on|off|flex|status]", - " /sf mcp MCP server status and connectivity [status|check |init [dir]]", + " /sf mcp External MCP server status [status|check ]", "", "MAINTENANCE", " /sf doctor Diagnose and repair .sf/ state [audit|fix|heal] [scope]", @@ -116,7 +116,7 @@ export function showHelp(ctx, args = "") { " /sf cleanup Remove merged branches or snapshots [branches|snapshots]", " /sf worktree Manage worktrees from the TUI [list|merge|clean|remove]", " /sf migrate Migrate .planning/ (v1) to .sf/ (v2) format", - " /sf remote Control remote auto-mode [slack|discord|status|disconnect]", + " /sf remote Control remote autonomous mode [slack|discord|status|disconnect]", " /sf inspect Show SQLite DB diagnostics (schema, row counts, recent entries)", " /sf update Update SF to the latest version via npm", ]; @@ -300,7 +300,9 @@ async function selectModelByProvider(title, models, ctx, currentModel) { return optionToModel.get(modelChoice); } async function resolveRequestedModel(query, ctx) { - const { resolveModelId } = await import("../../auto-model-selection.js"); + const { resolveModelId } = await import( + "../../autonomous model-selection.js" + ); const models = ctx.modelRegistry.getAvailable(); const exact = resolveModelId(query, models, ctx.model?.provider); if (exact) return exact; @@ -371,7 +373,7 @@ async function handleModel(trimmedArgs, ctx, pi) { } // /sf model is an explicit per-session pin for SF dispatches. // This is captured at auto bootstrap so it survives internal session - // switches during /sf auto and /sf next runs. + // switches during /sf autonomous and /sf next runs. const sessionId = ctx.sessionManager?.getSessionId?.(); if (sessionId) { setSessionModelOverride(sessionId, { diff --git a/src/resources/extensions/sf/commands/handlers/workflow.js b/src/resources/extensions/sf/commands/handlers/workflow.js index 07eac8c6d..07edf9f3e 100644 --- a/src/resources/extensions/sf/commands/handlers/workflow.js +++ b/src/resources/extensions/sf/commands/handlers/workflow.js @@ -38,11 +38,11 @@ const WORKFLOW_USAGE = [ "Usage: /sf workflow ", "", " new — Create a new workflow definition (via skill)", - " run [k=v] — Create a run and start auto-mode", + " run [k=v] — Create a run and start autonomous mode", " list [name] — List workflow runs (optionally filtered by name)", " validate — Validate a workflow definition YAML", - " pause — Pause custom workflow auto-mode", - " resume — Resume paused custom workflow auto-mode", + " pause — Pause custom workflow autonomous mode", + " resume — Resume paused custom workflow autonomous mode", ].join("\n"); function splitWorkflowRunArgs(input) { const tokens = []; @@ -136,7 +136,7 @@ async function handleCustomWorkflow(sub, ctx, pi) { ); startAutoDetached(ctx, pi, base, false); } catch (err) { - // Clean up engine state so a failed workflow run doesn't pollute the next /sf auto + // Clean up engine state so a failed workflow run doesn't pollute the next /sf autonomous setActiveEngineId(null); setActiveRunDir(null); const msg = err instanceof Error ? err.message : String(err); @@ -203,7 +203,7 @@ async function handleCustomWorkflow(sub, ctx, pi) { return true; } if (!isAutoActive()) { - ctx.ui.notify("Auto-mode is not active.", "warning"); + ctx.ui.notify("Autonomous mode is not active.", "warning"); return true; } await pauseAuto(ctx, pi); @@ -266,8 +266,8 @@ export async function handleWorkflowCommand(trimmed, ctx, pi) { if (trimmed === "quick" || trimmed.startsWith("quick ")) { if (isAutoActive()) { ctx.ui.notify( - "/sf quick cannot run while auto-mode is active.\n" + - "Stop auto-mode first with /sf stop, then run /sf quick.", + "/sf quick cannot run while autonomous mode is active.\n" + + "Stop autonomous mode first with /sf stop, then run /sf quick.", "error", ); return true; diff --git a/src/resources/extensions/sf/docs/preferences-reference.md b/src/resources/extensions/sf/docs/preferences-reference.md index f95e457ac..425623357 100644 --- a/src/resources/extensions/sf/docs/preferences-reference.md +++ b/src/resources/extensions/sf/docs/preferences-reference.md @@ -102,7 +102,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `custom_instructions`: extra durable instructions related to skill use. For operational project knowledge (recurring rules, gotchas, patterns), use `.sf/KNOWLEDGE.md` instead — it's injected into every agent prompt automatically and agents can append to it during execution. -- `models`: per-stage model selection (applies to both auto-mode and guided-flow dispatches). Keys: `research`, `planning`, `discuss`, `execution`, `execution_simple`, `completion`, `validation`, `subagent`. Values can be: +- `models`: per-stage model selection (applies to both autonomous mode and guided-flow dispatches). Keys: `research`, `planning`, `discuss`, `execution`, `execution_simple`, `completion`, `validation`, `subagent`. Values can be: - Simple string: `"claude-sonnet-4-6"` — single model, no fallbacks - Provider-qualified string: `"bedrock/claude-sonnet-4-6"` — targets a specific provider when the same model ID exists across multiple providers - Object with fallbacks: `{ model: "claude-opus-4-6", fallbacks: ["glm-5", "minimax-m2.5"] }` — tries fallbacks in order if primary fails @@ -111,16 +111,16 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `discuss` — used for milestone/slice discussion (interactive context gathering). Falls back to `planning` if unset. - `validation` — used for gate evaluation, roadmap reassessment, milestone validation, and doc rewrites. Falls back to `planning` if unset. -- `persist_model_changes`: boolean — controls whether `setModel()` updates also persist to the default provider/model settings. Default: `true`. Set `false` to keep auto-mode and recovery model switches session-local. +- `persist_model_changes`: boolean — controls whether `setModel()` updates also persist to the default provider/model settings. Default: `true`. Set `false` to keep autonomous mode and recovery model switches session-local. - `skill_staleness_days`: number — skills unused for this many days get deprioritized during discovery. Set to `0` to disable staleness tracking. Default: `60`. -- `skill_discovery`: controls how SF discovers and applies skills during auto-mode. Valid values: +- `skill_discovery`: controls how SF discovers and applies skills during autonomous mode. Valid values: - `auto` — skills are found and applied automatically without prompting. - `suggest` — (default) skills are identified during research but not installed automatically. - `off` — skill discovery is disabled entirely. -- `auto_supervisor`: configures the auto-mode supervisor that monitors agent progress and enforces timeouts. Keys: +- `auto_supervisor`: configures the autonomous mode supervisor that monitors agent progress and enforces timeouts. Keys: - `model`: model ID to use for the supervisor process (defaults to the currently active model). - `soft_timeout_minutes`: minutes before the supervisor issues a soft warning (default: 20). - `idle_timeout_minutes`: minutes of inactivity before the supervisor intervenes (default: 10). @@ -132,7 +132,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `runaway_elapsed_minutes`: elapsed unit minutes before a runaway warning (default: `20`; set `0` to disable this signal). - `runaway_changed_files_warning`: changed files since unit start before a runaway warning (default: `75`; set `0` to disable this signal). - `runaway_diagnostic_turns`: number of diagnostic turns the agent gets before pause if budget keeps growing (default: `2`). - - `runaway_hard_pause`: pause auto-mode after diagnostic turns if budget keeps growing (default: `true`). + - `runaway_hard_pause`: pause autonomous mode after diagnostic turns if budget keeps growing (default: `true`). - `git`: configures SF's git behavior. All fields are optional — omit any to use defaults. Keys: - `auto_push`: boolean — automatically push commits to the remote after committing. Default: `false`. @@ -143,9 +143,9 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `commit_type`: string — override the conventional commit type prefix. Must be one of: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`, `style`. Default: inferred from diff content. - `main_branch`: string — the primary branch name for new git repos (e.g., `"main"`, `"master"`, `"trunk"`). Also used by `getMainBranch()` as the preferred branch when auto-detection is ambiguous. Default: `"main"`. - `merge_strategy`: `"squash"` or `"merge"` — controls how worktree branches are merged back. `"squash"` combines all commits into one; `"merge"` preserves individual commits. Default: `"squash"`. - - `isolation`: `"worktree"`, `"branch"`, or `"none"` — controls auto-mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root but creates a milestone branch (useful for submodule-heavy repos); `"none"` works directly on the current branch with no worktree or milestone branch (ideal for step-mode with hot reloads). Default: `"worktree"`. + - `isolation`: `"worktree"`, `"branch"`, or `"none"` — controls autonomous mode git isolation strategy. `"worktree"` creates a milestone worktree for isolated work; `"branch"` works directly in the project root but creates a milestone branch (useful for submodule-heavy repos); `"none"` works directly on the current branch with no worktree or milestone branch (ideal for step-mode with hot reloads). Default: `"worktree"`. - `manage_gitignore`: boolean — when `false`, SF will not touch `.gitignore` at all. Useful when your project has a strictly managed `.gitignore` and you don't want SF adding entries. Default: `true`. - - `worktree_post_create`: string — script to run after a worktree is created (both auto-mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none. + - `worktree_post_create`: string — script to run after a worktree is created (both autonomous mode and manual `/worktree`). Receives `SOURCE_DIR` and `WORKTREE_DIR` as environment variables. Can be absolute or relative to project root. Runs with 30-second timeout. Failure is non-fatal (logged as warning). Default: none. - `auto_pr`: boolean — automatically create a GitHub pull request after a milestone branch is merged. Requires `gh` CLI to be installed. Default: `false`. - `pr_target_branch`: string — branch to target when `auto_pr` is enabled. Defaults to `main_branch` when omitted. - **Deprecated:** `commit_docs` — no longer valid; `.sf/` is always gitignored. Remove this setting. @@ -153,15 +153,15 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `unique_milestone_ids`: boolean — when `true`, generates milestone IDs in `M{seq}-{rand6}` format (e.g. `M001-eh88as`) instead of plain sequential `M001`. Prevents ID collisions in team workflows where multiple contributors create milestones concurrently. Both formats coexist — existing `M001`-style milestones remain valid. Default: `false`. -- `budget_ceiling`: number — maximum dollar amount to spend on auto-mode. When reached, behavior is controlled by `budget_enforcement`. Default: no limit. +- `budget_ceiling`: number — maximum dollar amount to spend on autonomous mode. When reached, behavior is controlled by `budget_enforcement`. Default: no limit. - `budget_enforcement`: `"warn"`, `"pause"`, or `"halt"` — action taken when `budget_ceiling` is reached. - `warn` — log a warning but continue execution. - - `pause` — pause auto-mode and wait for user confirmation. - - `halt` — stop auto-mode immediately. + - `pause` — pause autonomous mode and wait for user confirmation. + - `halt` — stop autonomous mode immediately. - Default: `"pause"`. -- `context_pause_threshold`: number (0-100) — context window usage percentage at which auto-mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). +- `context_pause_threshold`: number (0-100) — context window usage percentage at which autonomous mode should pause to suggest checkpointing. Set to `0` to disable. Default: `0` (disabled). - `token_profile`: `"budget"`, `"balanced"`, `"quality"`, or `"burn-max"` — coordinates model selection, phase skipping, and context compression. `budget` skips research/reassessment and uses cheaper models; `balanced` (default) skips research/reassessment to reduce token burn; `quality` prefers higher-quality models; `burn-max` keeps full-context defaults, disables downgrade routing, and keeps phase skips off. @@ -178,13 +178,13 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `indexer_backend`: `"sift"` or `"none"` — codebase-indexer backend used for prompt guidance and `/sf codebase indexer status`. Default: `"sift"`. - `/sf codebase indexer status` reports Sift status. Install `rupurt/sift` on `PATH` or set `SIFT_PATH`. -- `remote_questions`: route interactive questions to Slack/Discord for headless auto-mode. Keys: +- `remote_questions`: route interactive questions to Slack/Discord for headless autonomous mode. Keys: - `channel`: `"slack"` or `"discord"` — channel type. - `channel_id`: string or number — channel ID. - `timeout_minutes`: number — question timeout in minutes (clamped 1-30). - `poll_interval_seconds`: number — poll interval in seconds (clamped 2-30). -- `notifications`: configures desktop notification behavior during auto-mode. Keys: +- `notifications`: configures desktop notification behavior during autonomous mode. Keys: - `enabled`: boolean — master toggle for all notifications. Default: `true`. - `on_complete`: boolean — notify when a unit completes. Default: `true`. - `on_error`: boolean — notify on errors. Default: `true`. @@ -221,13 +221,13 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `audit_envelope.enabled`: boolean — dual-write audit envelope events. Default: `true`. - `planning_flow.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. Default: `true`. -- `context_management`: configures context hygiene for auto-mode sessions. Keys: +- `context_management`: configures context hygiene for autonomous mode sessions. Keys: - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`. - `observation_mask_turns`: number — keep this many recent turns verbatim (1-50). Default: `8`. - `compaction_threshold_percent`: number — trigger compaction at this % of context window (0.5-0.95). Lower values fire compaction earlier, reducing drift. Default: `0.70`. - `tool_result_max_chars`: number — max chars per tool result in SF sessions (200-10000). Default: `800`. -- `auto_visualize`: boolean — show a visualizer hint after each milestone completion in auto-mode. Default: `false`. +- `auto_visualize`: boolean — show a visualizer hint after each milestone completion in autonomous mode. Default: `false`. - `auto_report`: boolean — generate an HTML report snapshot after each milestone completion. Default: `true`. @@ -393,7 +393,7 @@ models: --- ``` -When a model fails to switch (provider unavailable, rate limited, credits exhausted), SF automatically tries the next model in the `fallbacks` list. This ensures auto-mode continues even when your preferred provider hits limits. +When a model fails to switch (provider unavailable, rate limited, credits exhausted), SF automatically tries the next model in the `fallbacks` list. This ensures autonomous mode continues even when your preferred provider hits limits. ## Provider Targeting @@ -524,7 +524,7 @@ context_pause_threshold: 80 --- ``` -Sets a $10 budget ceiling. Auto-mode pauses when the ceiling is reached. Context window pauses at 80% usage for checkpointing. +Sets a $10 budget ceiling. Autonomous mode pauses when the ceiling is reached. Context window pauses at 80% usage for checkpointing. --- @@ -661,7 +661,7 @@ remote_questions: --- ``` -Routes interactive questions to a Slack channel for headless auto-mode sessions. Questions time out after 15 minutes if unanswered. +Routes interactive questions to a Slack channel for headless autonomous mode sessions. Questions time out after 15 minutes if unanswered. --- diff --git a/src/resources/extensions/sf/guided-flow.js b/src/resources/extensions/sf/guided-flow.js index 66960e632..7a049fc3d 100644 --- a/src/resources/extensions/sf/guided-flow.js +++ b/src/resources/extensions/sf/guided-flow.js @@ -1391,7 +1391,7 @@ async function dispatchDiscussForMilestone( * Self-heal: scan runtime records and clear stale ones left behind when * auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords() * but guided-flow (manual /sf mode) never called it — meaning stale records - * persisted until the next /sf auto run. This ensures the workflow entry + * persisted until the next /sf autonomous run. This ensures the workflow entry * starts from a clean state regardless of how the previous session ended. */ function selfHealRuntimeRecords(basePath, ctx) { @@ -1691,7 +1691,7 @@ export async function showWorkflowEntry(ctx, pi, basePath, options) { if (!(await runPlanningFlowGate(ctx, basePath, state))) return; if (!state.activeMilestone?.id) { // Guard: if a discuss session is already in flight, don't re-inject the prompt. - // Both /sf and /sf auto reach this branch when no milestone exists yet. + // Both /sf and /sf autonomous reach this branch when no milestone exists yet. // Without this guard, every subsequent /sf call overwrites the pending auto-start // and fires another dispatchWorkflow, resetting the conversation mid-interview. if (pendingAutoStartMap.has(basePath)) { diff --git a/src/resources/extensions/sf/init-wizard.js b/src/resources/extensions/sf/init-wizard.js index 60fc71938..1ffd21423 100644 --- a/src/resources/extensions/sf/init-wizard.js +++ b/src/resources/extensions/sf/init-wizard.js @@ -280,12 +280,6 @@ export async function showProjectInit(ctx, _pi, basePath, detection) { } catch { // Non-fatal — STATE.md will be regenerated on next /sf invocation } - { - const { prepareWorkflowMcpForProject } = await import( - "./workflow-mcp-auto-prep.js" - ); - prepareWorkflowMcpForProject(ctx, basePath); - } ctx.ui.notify("SF initialized. Starting your first milestone...", "info"); return { completed: true, bootstrapped: true }; } diff --git a/src/resources/extensions/sf/mcp-project-config.js b/src/resources/extensions/sf/mcp-project-config.js deleted file mode 100644 index 934c5fcae..000000000 --- a/src/resources/extensions/sf/mcp-project-config.js +++ /dev/null @@ -1,98 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { assertSafeDirectory } from "./validate-directory.js"; -import { detectWorkflowMcpLaunchConfig } from "./workflow-mcp.js"; -/** Name identifier for the SF workflow MCP server. */ -export const SF_WORKFLOW_MCP_SERVER_NAME = "sf-workflow"; -export function resolveBundledSfCliPath(env = process.env) { - const explicit = env.SF_CLI_PATH?.trim() || env.SF_BIN_PATH?.trim(); - if (explicit) return explicit; - const candidates = [ - resolve( - fileURLToPath(new URL("../../../../scripts/dev-cli.js", import.meta.url)), - ), - resolve( - fileURLToPath(new URL("../../../../dist/loader.js", import.meta.url)), - ), - resolve(fileURLToPath(new URL("../../../loader.js", import.meta.url))), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} -export function buildProjectWorkflowMcpServerConfig( - projectRoot, - env = process.env, -) { - const resolvedProjectRoot = resolve(projectRoot); - const sfCliPath = resolveBundledSfCliPath(env); - const launch = detectWorkflowMcpLaunchConfig(resolvedProjectRoot, { - ...env, - ...(sfCliPath ? { SF_CLI_PATH: sfCliPath, SF_BIN_PATH: sfCliPath } : {}), - }); - if (!launch) { - throw new Error( - "Unable to resolve the SF workflow MCP server. Build this checkout or install sf-mcp-server on PATH.", - ); - } - return { - command: launch.command, - ...(launch.args && launch.args.length > 0 ? { args: launch.args } : {}), - ...(launch.cwd ? { cwd: launch.cwd } : {}), - ...(launch.env ? { env: launch.env } : {}), - }; -} -function readExistingConfig(configPath) { - if (!existsSync(configPath)) return {}; - const raw = readFileSync(configPath, "utf-8"); - try { - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch (err) { - throw new Error( - `Failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } -} -export function ensureProjectWorkflowMcpConfig(projectRoot, env = process.env) { - const resolvedProjectRoot = resolve(projectRoot); - assertSafeDirectory(resolvedProjectRoot); - const configPath = resolve(resolvedProjectRoot, ".mcp.json"); - const existing = readExistingConfig(configPath); - const desiredServer = buildProjectWorkflowMcpServerConfig( - resolvedProjectRoot, - env, - ); - const previousServers = existing.mcpServers ?? {}; - const nextServers = { - ...previousServers, - [SF_WORKFLOW_MCP_SERVER_NAME]: desiredServer, - }; - const alreadyPresent = existsSync(configPath); - const unchanged = - JSON.stringify(previousServers[SF_WORKFLOW_MCP_SERVER_NAME] ?? null) === - JSON.stringify(desiredServer) && existing.mcpServers !== undefined; - if (unchanged) { - return { - configPath, - serverName: SF_WORKFLOW_MCP_SERVER_NAME, - status: "unchanged", - }; - } - const nextConfig = { - ...existing, - mcpServers: nextServers, - }; - writeFileSync( - configPath, - `${JSON.stringify(nextConfig, null, 2)}\n`, - "utf-8", - ); - return { - configPath, - serverName: SF_WORKFLOW_MCP_SERVER_NAME, - status: alreadyPresent ? "updated" : "created", - }; -} diff --git a/src/resources/extensions/sf/parallel-orchestrator.js b/src/resources/extensions/sf/parallel-orchestrator.js index 11253f92a..73b0c066c 100644 --- a/src/resources/extensions/sf/parallel-orchestrator.js +++ b/src/resources/extensions/sf/parallel-orchestrator.js @@ -493,10 +493,10 @@ function createMilestoneWorktree(basePath, milestoneId) { // ─── Worker Spawning ─────────────────────────────────────────────────── /** * Spawn a worker process for a milestone. - * The worker runs `sf headless --json auto` in the milestone's worktree + * The worker runs `sf headless --json autonomous` in the milestone's worktree * with SF_MILESTONE_LOCK set to isolate state derivation. * - * IMPORTANT: We use `headless --json auto` instead of `--print "/sf auto"`. + * IMPORTANT: We use `headless --json autonomous` instead of `--print "/sf autonomous"`. * --print mode calls session.prompt() which returns immediately after the * extension command handler fires, because auto-mode's ctx.newSession() * resets the session and unblocks the outer prompt() await. This causes @@ -544,10 +544,10 @@ export function spawnWorker(basePath, milestoneId) { binPath, "headless", "--json", - "auto", + "autonomous", ], ] - : [process.execPath, [binPath, "headless", "--json", "auto"]]; + : [process.execPath, [binPath, "headless", "--json", "autonomous"]]; child = spawn(spawnCmd, spawnArgs, { cwd: worker.worktreePath, env: workerEnv, diff --git a/src/resources/extensions/sf/production-mutation-approval.js b/src/resources/extensions/sf/production-mutation-approval.js index 9782bde66..640fdd9e6 100644 --- a/src/resources/extensions/sf/production-mutation-approval.js +++ b/src/resources/extensions/sf/production-mutation-approval.js @@ -49,7 +49,7 @@ export function buildProductionMutationApprovalTemplate(unit) { instructions: [ "Human and LLM approvals are both valid when they name a safe non-customer-impacting target or a constrained target-selection plan.", "Fill status, approval.approved, approvedBy, approverType, approvedAt, safeServerId or targetSelectionPlan, safeVmNames or targetSelectionPlan, cleanupPlan, and rollbackPlan.", - "Then rerun sf headless auto.", + "Then rerun sf headless autonomous.", ], }; } diff --git a/src/resources/extensions/sf/prompts/complete-milestone.md b/src/resources/extensions/sf/prompts/complete-milestone.md index b0a51fdd3..cb83ec60f 100644 --- a/src/resources/extensions/sf/prompts/complete-milestone.md +++ b/src/resources/extensions/sf/prompts/complete-milestone.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Complete Milestone {{milestoneId}} ("{{milestoneTitle}}") diff --git a/src/resources/extensions/sf/prompts/complete-slice.md b/src/resources/extensions/sf/prompts/complete-slice.md index 7536c1f87..101f005d4 100644 --- a/src/resources/extensions/sf/prompts/complete-slice.md +++ b/src/resources/extensions/sf/prompts/complete-slice.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Complete Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} @@ -36,7 +36,7 @@ Then: 12. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds. 13. Update `.sf/PROJECT.md` if it exists — refresh current state if needed: use the `write` tool with `path: ".sf/PROJECT.md"` and `content` containing the full updated document reflecting current project state. Do NOT use the `edit` tool for this — PROJECT.md is a full-document refresh. -**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in auto-mode — there is no human available to answer questions. Make reasonable assumptions and document them in the slice summary. If a decision genuinely requires human input, note it in the summary and proceed with the best available option. +**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in autonomous mode — there is no human available to answer questions. Make reasonable assumptions and document them in the slice summary. If a decision genuinely requires human input, note it in the summary and proceed with the best available option. **File system safety:** Task summaries are preloaded in the inlined context above. Task artifacts use a **flat file layout** — files such as `T01-SUMMARY.md` and `T02-SUMMARY.md` live directly inside the `tasks/` directory, not inside per-task subdirectories like `tasks/T01/SUMMARY.md`. If you need to re-read any of them, use `find .sf/milestones/{{milestoneId}}/slices/{{sliceId}}/tasks -name "*-SUMMARY.md"` to list file paths first. Never use `tasks/*/SUMMARY.md`, and never pass `{{slicePath}}` or any other directory path directly to the `read` tool. The `read` tool only accepts file paths, not directories. diff --git a/src/resources/extensions/sf/prompts/discuss-headless.md b/src/resources/extensions/sf/prompts/discuss-headless.md index 84b10b85b..f996973fb 100644 --- a/src/resources/extensions/sf/prompts/discuss-headless.md +++ b/src/resources/extensions/sf/prompts/discuss-headless.md @@ -249,7 +249,7 @@ After writing final context and roadmap, say exactly: "Milestone {{milestoneId}} #### MANDATORY: depends_on Frontmatter in CONTEXT.md -Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The auto-mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't. +Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The autonomous mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't. ```yaml --- @@ -266,8 +266,8 @@ If a milestone has no dependencies, omit the frontmatter. Do NOT rely on QUEUE.m For each remaining milestone, in dependency order, autonomously decide the best readiness mode: - **Write full context** — if the spec provides enough detail for this milestone and investigation confirms feasibility. Write a full `CONTEXT.md` with technical assumptions verified against the actual codebase. -- **Write draft for later** — if the spec has seed material but the milestone needs its own investigation/research in a future session. Write a `CONTEXT-DRAFT.md` capturing seed material, key ideas, provisional scope, and open questions. **Downstream:** Auto-mode pauses at this milestone and prompts the user to discuss. -- **Just queue it** — if the milestone is identified but the spec provides no actionable detail. No context file written. **Downstream:** Auto-mode pauses and starts a full discussion from scratch. +- **Write draft for later** — if the spec has seed material but the milestone needs its own investigation/research in a future session. Write a `CONTEXT-DRAFT.md` capturing seed material, key ideas, provisional scope, and open questions. **Downstream:** Autonomous mode pauses at this milestone and prompts the user to discuss. +- **Just queue it** — if the milestone is identified but the spec provides no actionable detail. No context file written. **Downstream:** Autonomous mode pauses and starts a full discussion from scratch. **Default to writing full context** when the spec is detailed enough. Default to draft when the spec mentions the milestone but is vague. Default to queue when the milestone is implied by the vision but not described. @@ -318,6 +318,6 @@ After writing final context and roadmap, say exactly: "Milestone {{milestoneId}} - **Use depends_on frontmatter** for multi-milestone sequences - **Anti-reduction rule** — if the spec describes a big vision, plan the big vision. Phase complexity — don't cut it. - **Naming convention** — always use `sf_milestone_generate_id` for IDs. Directories use bare IDs, files use ID-SUFFIX format. -- **End with "Milestone {{milestoneId}} ready." only after final context and roadmap exist.** Draft output must end with "Milestone {{milestoneId}} drafted for discussion." so auto-mode does not start from shallow knowledge. +- **End with "Milestone {{milestoneId}} ready." only after final context and roadmap exist.** Draft output must end with "Milestone {{milestoneId}} drafted for discussion." so autonomous mode does not start from shallow knowledge. {{inlinedTemplates}} diff --git a/src/resources/extensions/sf/prompts/discuss.md b/src/resources/extensions/sf/prompts/discuss.md index 7ca493d26..e6fcee6f1 100644 --- a/src/resources/extensions/sf/prompts/discuss.md +++ b/src/resources/extensions/sf/prompts/discuss.md @@ -348,7 +348,7 @@ These sections are in addition to whatever other context the discussion surfaced 6. For each architectural or pattern decision made during discussion, call `sf_decision_save` — the tool auto-assigns IDs and regenerates `.sf/DECISIONS.md` automatically. 7. {{commitInstruction}} -After writing the files, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Auto-mode will start automatically. +After writing the files, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Autonomous mode will start automatically. ### Multi-Milestone @@ -368,7 +368,7 @@ Once the user confirms the milestone split: #### MANDATORY: depends_on Frontmatter in CONTEXT.md -Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The auto-mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't. +Every CONTEXT.md for a milestone that depends on other milestones MUST have YAML frontmatter with `depends_on`. The autonomous mode state machine reads this field to determine execution order — without it, milestones may execute out of order or in parallel when they shouldn't. ```yaml --- @@ -385,8 +385,8 @@ If a milestone has no dependencies, omit the frontmatter. The dependency chain f For each remaining milestone **one at a time, in sequence**, decide the most likely readiness mode from the evidence you already have, then present the three options below to the user. **If `{{structuredQuestionsAvailable}}` is `true`:** use `ask_user_questions`. **If `{{structuredQuestionsAvailable}}` is `false`:** present the options as a plain-text numbered list and ask the user to type their choice. **Non-bypassable:** If the user does not respond, gives an ambiguous answer, or the tool fails, you MUST re-ask — never rationalize past the block or auto-select a readiness mode. Present three options: - **"Discuss now"** — The user wants to conduct a focused discussion for this milestone in the current session, while the context from the broader discussion is still fresh. Proceed with a focused discussion for this milestone (reflection → investigation → questioning → depth verification). When the discussion concludes, write a full `CONTEXT.md`. Then move to the gate for the next milestone. -- **"Write draft for later"** — This milestone has seed material from the current conversation but needs its own dedicated discussion in a future session. Write a `CONTEXT-DRAFT.md` capturing the seed material (what was discussed, key ideas, provisional scope, open questions). Mark it clearly as a draft, not a finalized context. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user: "M00x has draft context — needs discussion. Run /sf." The `/sf` wizard shows a "Discuss from draft" option that seeds the new discussion with this draft, so nothing from the current conversation is lost. After the dedicated discussion produces a full CONTEXT.md, the draft file is automatically deleted. -- **"Just queue it"** — This milestone is identified but intentionally left without context. No context file is written — the directory already exists from Phase 1. **What happens downstream:** When auto-mode reaches this milestone, it pauses and notifies the user to run /sf. The wizard starts a full discussion from scratch. +- **"Write draft for later"** — This milestone has seed material from the current conversation but needs its own dedicated discussion in a future session. Write a `CONTEXT-DRAFT.md` capturing the seed material (what was discussed, key ideas, provisional scope, open questions). Mark it clearly as a draft, not a finalized context. **What happens downstream:** When autonomous mode reaches this milestone, it pauses and notifies the user: "M00x has draft context — needs discussion. Run /sf." The `/sf` wizard shows a "Discuss from draft" option that seeds the new discussion with this draft, so nothing from the current conversation is lost. After the dedicated discussion produces a full CONTEXT.md, the draft file is automatically deleted. +- **"Just queue it"** — This milestone is identified but intentionally left without context. No context file is written — the directory already exists from Phase 1. **What happens downstream:** When autonomous mode reaches this milestone, it pauses and notifies the user to run /sf. The wizard starts a full discussion from scratch. **When "Discuss now" is chosen — Technical Assumption Verification is MANDATORY:** @@ -404,7 +404,7 @@ Each context file (full or draft) should be rich enough that a future agent enco #### Milestone Gate Tracking (MANDATORY for multi-milestone) -After EVERY Phase 3 gate decision, immediately write or update `.sf/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before auto-mode starts — if gates are incomplete, auto-mode will NOT start. *(This is transient session state, not a tracked deliverable — it lives in `.sf/` during the discussion and is cleaned up afterward.)* +After EVERY Phase 3 gate decision, immediately write or update `.sf/DISCUSSION-MANIFEST.json` with the cumulative state. This file is mechanically validated by the system before autonomous mode starts — if gates are incomplete, autonomous mode will NOT start. *(This is transient session state, not a tracked deliverable — it lives in `.sf/` during the discussion and is cleaned up afterward.)* ```json { @@ -427,6 +427,6 @@ For single-milestone projects, do NOT write this file — it is only for multi-m 7. {{multiMilestoneCommitInstruction}} -After writing the files, say exactly: "Milestone M001 ready." — nothing else. Auto-mode will start automatically. +After writing the files, say exactly: "Milestone M001 ready." — nothing else. Autonomous mode will start automatically. {{inlinedTemplates}} diff --git a/src/resources/extensions/sf/prompts/execute-task.md b/src/resources/extensions/sf/prompts/execute-task.md index 02fedd108..f5d0b0893 100644 --- a/src/resources/extensions/sf/prompts/execute-task.md +++ b/src/resources/extensions/sf/prompts/execute-task.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Execute Task {{taskId}} ("{{taskTitle}}") — Slice {{sliceId}} ("{{sliceTitle}}"), Milestone {{milestoneId}} @@ -94,7 +94,7 @@ Then: All work stays in your working directory: `{{workingDirectory}}`. -**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in auto-mode — there is no human available to answer questions. Make reasonable assumptions and document them in the task summary. If a decision genuinely requires human input, note it in the summary and proceed with the best available option. +**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in autonomous mode — there is no human available to answer questions. Make reasonable assumptions and document them in the task summary. If a decision genuinely requires human input, note it in the summary and proceed with the best available option. {{escalationGuidance}} diff --git a/src/resources/extensions/sf/prompts/forensics.md b/src/resources/extensions/sf/prompts/forensics.md index 8e20e3579..5e58140ef 100644 --- a/src/resources/extensions/sf/prompts/forensics.md +++ b/src/resources/extensions/sf/prompts/forensics.md @@ -16,7 +16,7 @@ SF extension source code is at: `{{sfSourceDir}}` | Domain | Files | |--------|-------| -| **Auto-mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `auto-model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` | +| **Autonomous mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `autonomous model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` | | **State & persistence** | `state.ts` `types.ts` `files.ts` `paths.ts` `json-persistence.ts` `atomic-write.ts` | | **Forensics & recovery** | `forensics.ts` `session-forensics.ts` `crash-recovery.ts` `session-lock.ts` | | **Metrics & telemetry** | `metrics.ts` `skill-telemetry.ts` `token-counter.ts` | @@ -64,7 +64,7 @@ SF extension source code is at: `{{sfSourceDir}}` ### Journal Format (`.sf/journal/`) -The journal is a structured event log for auto-mode iterations. Each daily file contains JSONL entries: +The journal is a structured event log for autonomous mode iterations. Each daily file contains JSONL entries: ``` { ts: "ISO-8601", flowId: "UUID", seq: 0, eventType: "iteration-start", rule?: "rule-name", causedBy?: { flowId, seq }, data?: { unitId, status, ... } } @@ -72,9 +72,9 @@ The journal is a structured event log for auto-mode iterations. Each daily file **Key event types:** - `iteration-start` / `iteration-end` — marks loop iteration boundaries -- `dispatch-match` / `dispatch-stop` — what the auto-mode decided to do (or not do) +- `dispatch-match` / `dispatch-stop` — what the autonomous mode decided to do (or not do) - `unit-start` / `unit-end` — lifecycle of individual work units -- `terminal` — auto-mode reached a terminal state (all done, budget exceeded, etc.) +- `terminal` — autonomous mode reached a terminal state (all done, budget exceeded, etc.) - `guard-block` — dispatch was blocked by a guard condition (e.g. needs user input) - `stuck-detected` — the loop detected it was stuck (same unit repeatedly dispatched) - `milestone-transition` — a milestone was promoted or completed @@ -92,7 +92,7 @@ The journal is a structured event log for auto-mode iterations. Each daily file JSON with fields: `pid`, `startedAt`, `unitType`, `unitId`, `unitStartedAt`, `completedUnits`, `sessionFile` -A stale lock (PID is dead) means the previous auto-mode session crashed mid-unit. +A stale lock (PID is dead) means the previous autonomous mode session crashed mid-unit. ### Metrics Ledger Format (`metrics.json`) @@ -108,9 +108,9 @@ A unit dispatched more than once (`type/id` appears multiple times) indicates a 1. **Start with the pre-parsed forensic report** above. The anomaly section contains automated findings — treat these as leads, not conclusions. -2. **Check the journal timeline** if present. The journal events show the auto-mode's decision sequence (dispatches, guards, stuck detection, worktree operations). Use flow IDs to group related events and trace causal chains. +2. **Check the journal timeline** if present. The journal events show the autonomous mode's decision sequence (dispatches, guards, stuck detection, worktree operations). Use flow IDs to group related events and trace causal chains. -3. **Cross-reference activity logs and journal**. Activity logs show *what the LLM did* (tool calls, reasoning, errors). Journal events show *what auto-mode decided* (dispatch rules, iteration boundaries, state transitions). Together they reveal the full picture. +3. **Cross-reference activity logs and journal**. Activity logs show *what the LLM did* (tool calls, reasoning, errors). Journal events show *what autonomous mode decided* (dispatch rules, iteration boundaries, state transitions). Together they reveal the full picture. 4. **Form hypotheses** about which module and code path is responsible. Use the source map to identify candidate files. diff --git a/src/resources/extensions/sf/prompts/plan-milestone.md b/src/resources/extensions/sf/prompts/plan-milestone.md index e1e09d26e..282e49fbb 100644 --- a/src/resources/extensions/sf/prompts/plan-milestone.md +++ b/src/resources/extensions/sf/prompts/plan-milestone.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Plan Milestone {{milestoneId}} ("{{milestoneTitle}}") diff --git a/src/resources/extensions/sf/prompts/plan-slice.md b/src/resources/extensions/sf/prompts/plan-slice.md index 5dd005e08..5de2c4d7d 100644 --- a/src/resources/extensions/sf/prompts/plan-slice.md +++ b/src/resources/extensions/sf/prompts/plan-slice.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Plan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} @@ -124,7 +124,7 @@ Then: The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. All work stays in your working directory: `{{workingDirectory}}`. -**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in auto-mode — there is no human available to answer questions. Make reasonable assumptions and document them in the plan. If a decision genuinely requires human input, write a note in the relevant task's description and call `sf_plan_slice` with what you have. +**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in autonomous mode — there is no human available to answer questions. Make reasonable assumptions and document them in the plan. If a decision genuinely requires human input, write a note in the relevant task's description and call `sf_plan_slice` with what you have. **You MUST call `sf_plan_slice` to persist the planning state before finishing.** diff --git a/src/resources/extensions/sf/prompts/queue.md b/src/resources/extensions/sf/prompts/queue.md index 7964034dc..aad4d9c89 100644 --- a/src/resources/extensions/sf/prompts/queue.md +++ b/src/resources/extensions/sf/prompts/queue.md @@ -9,7 +9,7 @@ Before asking "What do you want to add?", check the existing milestones context 1. Tell the user which milestones have draft contexts and briefly summarize what each draft contains (read the draft file). 2. Use `ask_user_questions` to ask per-draft milestone: - **"Discuss now"** — Treat this draft as the primary topic. Read the draft content, use it as seed material, and conduct a focused discussion following the standard discussion flow (reflection → investigation → questioning → depth verification → requirements → roadmap). After the discussion, call `sf_summary_save` with the milestone ID and `artifact_type: "CONTEXT"` to write the full context — then delete the `CONTEXT-DRAFT.md` file. The milestone is then ready for auto-planning. - - **"Leave for later"** — Keep the draft as-is. The user will discuss it in a future session. Auto-mode will continue to pause when it reaches this milestone. + - **"Leave for later"** — Keep the draft as-is. The user will discuss it in a future session. Autonomous mode will continue to pause when it reaches this milestone. 3. Handle all draft discussions before proceeding to new queue work. 4. If no drafts exist in the context, skip this section entirely and proceed to "What do you want to add?" @@ -111,13 +111,13 @@ The user confirms or corrects before you write. One depth verification per miles Once the user is satisfied, in a single pass for **each** new milestone: 1. Call `sf_milestone_generate_id` to get the milestone ID — never invent milestone IDs manually. Then `mkdir -p .sf/milestones//slices`. -2. Call `sf_summary_save` with `milestone_id: `, `artifact_type: "CONTEXT"`, and the full context markdown as `content` — the tool computes the file path and persists to both DB and disk. Capture intent, scope, risks, constraints, integration points, and relevant requirements in the content. Mark the status as "Queued — pending auto-mode execution." **If this milestone depends on other milestones, include YAML frontmatter with `depends_on` in the content:** +2. Call `sf_summary_save` with `milestone_id: `, `artifact_type: "CONTEXT"`, and the full context markdown as `content` — the tool computes the file path and persists to both DB and disk. Capture intent, scope, risks, constraints, integration points, and relevant requirements in the content. Mark the status as "Queued — pending autonomous mode execution." **If this milestone depends on other milestones, include YAML frontmatter with `depends_on` in the content:** ```yaml --- depends_on: [M001, M002] --- ``` - The auto-mode state machine reads this field to enforce execution order. Without it, milestones may execute out of order. List the exact milestone IDs (including any suffix like `-0zjrg0`) from the dependency chain discussed with the user. + The autonomous mode state machine reads this field to enforce execution order. Without it, milestones may execute out of order. List the exact milestone IDs (including any suffix like `-0zjrg0`) from the dependency chain discussed with the user. Then, after all milestone directories and context files are written: @@ -130,7 +130,7 @@ Then, after all milestone directories and context files are written: **Do NOT write roadmaps for queued milestones.** **Do NOT update `.sf/STATE.md`.** -After writing the files and committing, say exactly: "Queued N milestone(s). Auto-mode will pick them up after current work completes." — nothing else. +After writing the files and committing, say exactly: "Queued N milestone(s). Autonomous mode will pick them up after current work completes." — nothing else. ### Report sf-internal observations diff --git a/src/resources/extensions/sf/prompts/quick-task.md b/src/resources/extensions/sf/prompts/quick-task.md index 2f02fd55e..288a12bab 100644 --- a/src/resources/extensions/sf/prompts/quick-task.md +++ b/src/resources/extensions/sf/prompts/quick-task.md @@ -21,7 +21,7 @@ You are executing a SF quick task — a lightweight, focused unit of work outsid - Use conventional commit messages (feat:, fix:, refactor:, etc.) - Stage only relevant files — never commit secrets or runtime files. - Commit logical units separately if the task involves distinct changes. - - Quick tasks run outside the auto-mode lifecycle — there is no system auto-commit, so commit directly here. + - Quick tasks run outside the autonomous mode lifecycle — there is no system auto-commit, so commit directly here. 7. Write a brief summary to `{{summaryPath}}`: - Quick tasks operate outside the milestone/slice/task DB structure, so `sf_summary_save` (which requires a `milestone_id`) cannot be used here. Write the file directly. diff --git a/src/resources/extensions/sf/prompts/reassess-roadmap.md b/src/resources/extensions/sf/prompts/reassess-roadmap.md index d4e4600e0..899b19033 100644 --- a/src/resources/extensions/sf/prompts/reassess-roadmap.md +++ b/src/resources/extensions/sf/prompts/reassess-roadmap.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Reassess Roadmap — Milestone {{milestoneId}} after {{completedSliceId}} diff --git a/src/resources/extensions/sf/prompts/refine-slice.md b/src/resources/extensions/sf/prompts/refine-slice.md index 34dc19d6e..2e04c8fe8 100644 --- a/src/resources/extensions/sf/prompts/refine-slice.md +++ b/src/resources/extensions/sf/prompts/refine-slice.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Refine Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} @@ -72,7 +72,7 @@ Then: The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. -**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in auto-mode — there is no human available to answer questions. Make reasonable assumptions and document them in the plan. If a decision genuinely requires human input, write a note in the relevant task's description and call `sf_plan_slice` with what you have. +**Autonomous execution:** Do not call `ask_user_questions` or `secure_env_collect`. You are running in autonomous mode — there is no human available to answer questions. Make reasonable assumptions and document them in the plan. If a decision genuinely requires human input, write a note in the relevant task's description and call `sf_plan_slice` with what you have. **You MUST call `sf_plan_slice` to persist the planning state before finishing.** After it returns successfully, the pipeline will automatically clear the sketch flag on the next state derivation (the on-disk PLAN file is the signal). diff --git a/src/resources/extensions/sf/prompts/replan-slice.md b/src/resources/extensions/sf/prompts/replan-slice.md index e6012c437..58f9c2a5d 100644 --- a/src/resources/extensions/sf/prompts/replan-slice.md +++ b/src/resources/extensions/sf/prompts/replan-slice.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Replan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} diff --git a/src/resources/extensions/sf/prompts/research-milestone.md b/src/resources/extensions/sf/prompts/research-milestone.md index 9998c8715..f14834dbb 100644 --- a/src/resources/extensions/sf/prompts/research-milestone.md +++ b/src/resources/extensions/sf/prompts/research-milestone.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Research Milestone {{milestoneId}} ("{{milestoneTitle}}") diff --git a/src/resources/extensions/sf/prompts/research-slice.md b/src/resources/extensions/sf/prompts/research-slice.md index 8d91d3834..c0c27ab1f 100644 --- a/src/resources/extensions/sf/prompts/research-slice.md +++ b/src/resources/extensions/sf/prompts/research-slice.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Research Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} diff --git a/src/resources/extensions/sf/prompts/rethink.md b/src/resources/extensions/sf/prompts/rethink.md index 2b8ac5cb2..337ee8669 100644 --- a/src/resources/extensions/sf/prompts/rethink.md +++ b/src/resources/extensions/sf/prompts/rethink.md @@ -46,12 +46,12 @@ reason: "" Remove the `{ID}-PARKED.md` file from the milestone directory to reactivate it. ### Skip a slice -Mark a slice as skipped so auto-mode advances past it without executing. **You MUST call the `sf_skip_slice` tool** — editing the roadmap markdown alone is NOT sufficient because auto-mode reads slice status from the database, not the roadmap file: +Mark a slice as skipped so autonomous mode advances past it without executing. **You MUST call the `sf_skip_slice` tool** — editing the roadmap markdown alone is NOT sufficient because autonomous mode reads slice status from the database, not the roadmap file: ``` sf_skip_slice({ milestoneId: "M003", sliceId: "S02", reason: "Descoped — feature moved to M005" }) ``` Skipped slices are treated as closed by the state machine (like "complete" but distinct). Use when a slice is no longer needed or has been superseded. The slice data is preserved for reference. -**Do NOT** just check the slice checkbox in the roadmap — this does not update the DB and auto-mode will resume the slice. +**Do NOT** just check the slice checkbox in the roadmap — this does not update the DB and autonomous mode will resume the slice. **CRITICAL — Non-bypassable gate:** Skipping a slice is a permanent DB operation. You MUST confirm with the user before calling `sf_skip_slice`. If the user does not respond or gives an ambiguous answer, you MUST re-ask — never proceed without explicit approval. diff --git a/src/resources/extensions/sf/prompts/review-migration.md b/src/resources/extensions/sf/prompts/review-migration.md index a84f3d000..f24b1d8e4 100644 --- a/src/resources/extensions/sf/prompts/review-migration.md +++ b/src/resources/extensions/sf/prompts/review-migration.md @@ -63,7 +63,7 @@ Issues: Fixes applied: ``` -If the overall result is FAIL, explain what needs manual attention. If PASS WITH NOTES, explain what's imperfect but acceptable. If PASS, confirm the `.sf` directory is ready for SF-2 auto-mode. +If the overall result is FAIL, explain what needs manual attention. If PASS WITH NOTES, explain what's imperfect but acceptable. If PASS, confirm the `.sf` directory is ready for SF-2 autonomous mode. ### Report sf-internal observations diff --git a/src/resources/extensions/sf/prompts/rewrite-docs.md b/src/resources/extensions/sf/prompts/rewrite-docs.md index acdcb1c49..f2bada031 100644 --- a/src/resources/extensions/sf/prompts/rewrite-docs.md +++ b/src/resources/extensions/sf/prompts/rewrite-docs.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Rewrite Documents — Apply Override(s) for Milestone {{milestoneId}} ("{{milestoneTitle}}") diff --git a/src/resources/extensions/sf/prompts/run-uat.md b/src/resources/extensions/sf/prompts/run-uat.md index e3d5d226b..c16f2cf04 100644 --- a/src/resources/extensions/sf/prompts/run-uat.md +++ b/src/resources/extensions/sf/prompts/run-uat.md @@ -1,4 +1,4 @@ -You are executing SF auto-mode. +You are executing SF autonomous mode. ## UNIT: Run UAT — {{milestoneId}}/{{sliceId}} diff --git a/src/resources/extensions/sf/prompts/system.md b/src/resources/extensions/sf/prompts/system.md index e6f4ce82f..d02814049 100644 --- a/src/resources/extensions/sf/prompts/system.md +++ b/src/resources/extensions/sf/prompts/system.md @@ -14,7 +14,7 @@ You write code that's secure, performant, and clean. Not because someone told yo You finish what you start. You don't stub out implementations with TODOs and move on. You don't hardcode values where real logic belongs. You don't skip error handling because the happy path works. You don't build 80% of a feature and declare it done. If the task says build a login flow, the login flow works - with validation, error states, edge cases, the lot. Other AI agents cut corners and ship half-finished work that looks complete until you test it. You're not that. -You write code that you'll have to debug later - and you know it. A future version of you will land in this codebase with no memory of writing it, armed with only tool calls and whatever signals the code emits. So you build for that: clear error messages with context, observable state transitions, structured logs that a grep can find, explicit failure modes instead of silent swallowing. You don't add observability because a checklist says to - you add it because you're the one who'll need it at 3am when auto-mode hits a wall. +You write code that you'll have to debug later - and you know it. A future version of you will land in this codebase with no memory of writing it, armed with only tool calls and whatever signals the code emits. So you build for that: clear error messages with context, observable state transitions, structured logs that a grep can find, explicit failure modes instead of silent swallowing. You don't add observability because a checklist says to - you add it because you're the one who'll need it at 3am when autonomous mode hits a wall. When you have momentum, it's visible - brief signals of forward motion between tool calls. When you hit something unexpected, you say so in a line. When you're uncertain, you state it plainly and test it. When something works, you move on. The work speaks. @@ -82,7 +82,7 @@ Titles live inside file content (headings, frontmatter), not in file or director STATE.md runtime/ (system-managed — dispatch state, do not edit) activity/ (system-managed — JSONL execution logs, do not edit) - worktrees/ (system-managed — auto-mode worktree checkouts, see below) + worktrees/ (system-managed — autonomous mode worktree checkouts, see below) milestones/ M001/ M001-CONTEXT.md (milestone brief — scope, goals, constraints. May not exist for early milestones) @@ -103,7 +103,7 @@ Titles live inside file content (headings, frontmatter), not in file or director ### Isolation Model -Auto-mode supports three isolation modes (configured in `.sf/PREFERENCES.md` under `git.isolation`): +Autonomous mode supports three isolation modes (configured in `.sf/PREFERENCES.md` under `git.isolation`): - **worktree** (default): Work happens in `.sf/worktrees//`, a full git worktree on the `milestone/` branch. Each worktree has its own working copy and `.sf/` directory. Squash-merged back to the integration branch on milestone completion. - **branch**: Work happens in the project root on a `milestone/` branch. No worktree directory — files are checked out in-place. @@ -111,7 +111,7 @@ Auto-mode supports three isolation modes (configured in `.sf/PREFERENCES.md` und In all modes, slices commit sequentially on the active branch; there are no per-slice branches. -**If you are executing in auto-mode, your working directory is shown in the Working Directory section of your prompt.** Use relative paths. Do not navigate to any other copy of the project. +**If you are executing in autonomous mode, your working directory is shown in the Working Directory section of your prompt.** Use relative paths. Do not navigate to any other copy of the project. ### Conventions @@ -143,9 +143,9 @@ Templates showing the expected format for each artifact type are in: - `/sf` - contextual wizard - `/sf autonomous` - auto-execute (fresh context per task) -- `/sf stop` - stop auto-mode +- `/sf stop` - stop autonomous mode - `/sf status` - progress dashboard overlay -- `/sf queue` - queue future milestones (safe while auto-mode is running) +- `/sf queue` - queue future milestones (safe while autonomous mode is running) - `/sf quick ` - quick task with SF guarantees (atomic commits, state tracking) but no milestone ceremony - `/sf codebase [generate|update|stats|indexer]` - manage fallback `.sf/CODEBASE.md` and Sift code search - `{{shortcutDashboard}}` - toggle dashboard overlay diff --git a/src/resources/extensions/sf/prompts/triage-captures.md b/src/resources/extensions/sf/prompts/triage-captures.md index 9e0b6532a..876b83afc 100644 --- a/src/resources/extensions/sf/prompts/triage-captures.md +++ b/src/resources/extensions/sf/prompts/triage-captures.md @@ -20,8 +20,8 @@ The user captured thoughts during execution using `/sf capture`. Your job is to For each capture, classify it as one of: -- **stop**: User directive to halt auto-mode immediately. Use when the user says "stop", "halt", "abort", "don't continue", "pause", or otherwise wants execution to cease. Auto-mode will pause after the current unit completes. Examples: "stop running", "halt execution", "don't continue". -- **backtrack**: User directive to abandon the current milestone and return to a previous one. The user believes earlier milestones missed critical features or need rework. Include the target milestone ID (e.g., M003) in the Resolution field. Auto-mode will pause and write a regression marker. Examples: "restart from M003", "go back to milestone 3", "M004 and M005 failed, restart from M003". +- **stop**: User directive to halt autonomous mode immediately. Use when the user says "stop", "halt", "abort", "don't continue", "pause", or otherwise wants execution to cease. Autonomous mode will pause after the current unit completes. Examples: "stop running", "halt execution", "don't continue". +- **backtrack**: User directive to abandon the current milestone and return to a previous one. The user believes earlier milestones missed critical features or need rework. Include the target milestone ID (e.g., M003) in the Resolution field. Autonomous mode will pause and write a regression marker. Examples: "restart from M003", "go back to milestone 3", "M004 and M005 failed, restart from M003". - **quick-task**: Small, self-contained, no downstream impact. Can be done in minutes without modifying the plan. Examples: fix a typo, add a missing import, tweak a config value. - **inject**: Belongs in the current slice but wasn't planned. Needs a new task added to the slice plan. Examples: add error handling to a module being built, add a missing test case for current work. - **defer**: Belongs in a future slice or milestone. Not urgent for current work. Examples: performance optimization, feature that depends on unbuilt infrastructure, nice-to-have enhancement. @@ -63,6 +63,6 @@ For each capture, classify it as one of: 4. **Summarize** what was triaged: how many captures, what classifications were assigned, and what actions are pending (e.g., "2 quick-tasks ready for execution, 1 deferred to S03"). -**Important:** Do NOT execute any resolutions. Only classify and update CAPTURES.md. Resolution execution happens separately (in auto-mode dispatch or manually by the user). +**Important:** Do NOT execute any resolutions. Only classify and update CAPTURES.md. Resolution execution happens separately (in autonomous mode dispatch or manually by the user). When done, say: "Triage complete." diff --git a/src/resources/extensions/sf/skills/brainstorming/SKILL.md b/src/resources/extensions/sf/skills/brainstorming/SKILL.md index c12c7ed74..d9becb478 100644 --- a/src/resources/extensions/sf/skills/brainstorming/SKILL.md +++ b/src/resources/extensions/sf/skills/brainstorming/SKILL.md @@ -22,7 +22,7 @@ For trivial changes (typo fix, dependency bump, lint cleanup), skip this skill a ## Skill Chain ``` -← prev: (entry point — user request, sf auto-mode trigger, or new milestone) +← prev: (entry point — user request, sf autonomous-mode trigger, or new milestone) → next: clarify-spec (if underspecified) → plan-slice → spec-first-tdd ``` diff --git a/src/resources/extensions/sf/skills/pm-planning/SKILL.md b/src/resources/extensions/sf/skills/pm-planning/SKILL.md index 1dfb20bc4..022a1d499 100644 --- a/src/resources/extensions/sf/skills/pm-planning/SKILL.md +++ b/src/resources/extensions/sf/skills/pm-planning/SKILL.md @@ -1,6 +1,6 @@ --- name: pm-planning -description: Apply product management thinking when analyzing a codebase to plan milestones. Use during autonomous planning (sf auto, discuss-headless) to discover what needs to be built, prioritize work, and define done. Synthesizes Working Backwards, JTBD, Opportunity-Solution Tree, RICE prioritization, and scoping/cutting frameworks adapted for software development planning. +description: Apply product management thinking when analyzing a codebase to plan milestones. Use during autonomous planning (sf autonomous, discuss-headless) to discover what needs to be built, prioritize work, and define done. Synthesizes Working Backwards, JTBD, Opportunity-Solution Tree, RICE prioritization, and scoping/cutting frameworks adapted for software development planning. --- # PM Planning: Autonomous Codebase-to-Roadmap Thinking diff --git a/src/resources/extensions/sf/skills/sf-headless/SKILL.md b/src/resources/extensions/sf/skills/sf-headless/SKILL.md index faaf01025..c4a07a4db 100644 --- a/src/resources/extensions/sf/skills/sf-headless/SKILL.md +++ b/src/resources/extensions/sf/skills/sf-headless/SKILL.md @@ -41,7 +41,7 @@ Extra flags for `new-milestone`: `--context ` (use `-` for stdin), `--cont ### 2. Run All Queued Work ```bash -sf headless auto +sf headless autonomous ``` Default command. Loops through all pending units until milestone complete or blocked. diff --git a/src/resources/extensions/sf/skills/systematic-debugging/SKILL.md b/src/resources/extensions/sf/skills/systematic-debugging/SKILL.md index d18bfe6fe..d0e131627 100644 --- a/src/resources/extensions/sf/skills/systematic-debugging/SKILL.md +++ b/src/resources/extensions/sf/skills/systematic-debugging/SKILL.md @@ -29,7 +29,7 @@ If you catch yourself proposing a fix before Phase 1 evidence is in writing — ## When to Run - A test fails, RED happens for the wrong reason, GREEN regresses, build fails, lint fails. -- An sf auto-loop unit hits an unexpected error, abandons, or stalls. +- An sf autonomous-loop unit hits an unexpected error, abandons, or stalls. - Production behaviour does not match the contract. - Reviewer reports a real bug. diff --git a/src/resources/extensions/sf/skills/working-in-parallel/SKILL.md b/src/resources/extensions/sf/skills/working-in-parallel/SKILL.md index 03f56fa61..2c0797c2d 100644 --- a/src/resources/extensions/sf/skills/working-in-parallel/SKILL.md +++ b/src/resources/extensions/sf/skills/working-in-parallel/SKILL.md @@ -70,7 +70,7 @@ git worktree remove ../singularity-forge-my-feature ## When to Use -- Another agent (sf auto-loop, another Claude session, a teammate) is working in the current directory. +- Another agent (sf autonomous-loop, another Claude session, a teammate) is working in the current directory. - A long-running build or test is in flight in one terminal and you need a parallel branch. - You're exploring a refactor that you may abandon — keep main clean. - You need to apply an upstream cherry-pick from `pi-mono` while a separate legacy port is in progress. diff --git a/src/resources/extensions/sf/workflow-mcp-auto-prep.js b/src/resources/extensions/sf/workflow-mcp-auto-prep.js deleted file mode 100644 index dd148b2a8..000000000 --- a/src/resources/extensions/sf/workflow-mcp-auto-prep.js +++ /dev/null @@ -1,53 +0,0 @@ -import { ensureProjectWorkflowMcpConfig } from "./mcp-project-config.js"; -import { usesWorkflowMcpTransport } from "./workflow-mcp.js"; - -function getAuthModeSafe(ctx, provider) { - if (!provider) return undefined; - const getAuthMode = ctx.modelRegistry?.getProviderAuthMode; - if (typeof getAuthMode !== "function") return undefined; - try { - return getAuthMode(provider); - } catch { - return undefined; - } -} -function hasClaudeCodeProvider(ctx) { - return getAuthModeSafe(ctx, "claude-code") === "externalCli"; -} -function isClaudeCodeProviderReady(ctx) { - const readyCheck = ctx.modelRegistry?.isProviderRequestReady; - if (typeof readyCheck !== "function") return false; - try { - return readyCheck("claude-code"); - } catch { - return false; - } -} -export function shouldAutoPrepareWorkflowMcp(ctx) { - const provider = ctx.model?.provider; - const baseUrl = ctx.model?.baseUrl; - const authMode = getAuthModeSafe(ctx, provider); - if (usesWorkflowMcpTransport(authMode, baseUrl)) return true; - if (provider === "claude-code") return true; - if (hasClaudeCodeProvider(ctx)) return true; - return isClaudeCodeProviderReady(ctx); -} -export function prepareWorkflowMcpForProject(ctx, projectRoot) { - if (!shouldAutoPrepareWorkflowMcp(ctx)) return null; - try { - const result = ensureProjectWorkflowMcpConfig(projectRoot); - if (result.status !== "unchanged") { - ctx.ui?.notify?.( - `Claude Code MCP prepared at ${result.configPath}`, - "info", - ); - } - return result; - } catch (err) { - ctx.ui?.notify?.( - `Claude Code MCP prep failed: ${err instanceof Error ? err.message : String(err)}. Detected Claude Code model but no workflow MCP. Please run /sf mcp init . from your project root.`, - "warning", - ); - return null; - } -} diff --git a/src/resources/extensions/sf/workflow-mcp.js b/src/resources/extensions/sf/workflow-mcp.js index 334765587..645b0d026 100644 --- a/src/resources/extensions/sf/workflow-mcp.js +++ b/src/resources/extensions/sf/workflow-mcp.js @@ -1,336 +1,3 @@ -import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const MCP_WORKFLOW_TOOL_SURFACE = new Set([ - "ask_user_questions", - "sf_decision_save", - "sf_complete_milestone", - "sf_journal_query", - "sf_milestone_generate_id", - "sf_milestone_status", - "sf_validate_milestone", - "sf_plan_task", - "sf_plan_milestone", - "sf_plan_slice", - "sf_replan_slice", - "sf_reassess_roadmap", - "sf_requirement_save", - "sf_requirement_update", - "sf_save_gate_result", - "sf_skip_slice", - "sf_slice_complete", - "sf_summary_save", - "sf_task_complete", -]); -function parseLookupOutput(output) { - return output.toString().trim().split(/\r?\n/)[0] ?? ""; -} -/** - * Parse a JSON-encoded environment variable or return undefined. - */ -function parseJsonEnv(env, name) { - const raw = env[name]; - if (!raw) return undefined; - try { - return JSON.parse(raw); - } catch { - throw new Error(`Invalid JSON in ${name}`); - } -} -/** - * Resolve a command path using which/where or return null if not found. - */ -function lookupCommand(command, platform = process.platform) { - const lookup = platform === "win32" ? `where ${command}` : `which ${command}`; - try { - const resolved = parseLookupOutput( - execSync(lookup, { timeout: 5_000, stdio: "pipe" }), - ); - return resolved || null; - } catch { - return null; - } -} -/** - * Search ancestor directories for the bundled workflow CLI. - */ -function findWorkflowCliFromAncestorPath(startPath) { - let current = resolve(startPath); - while (true) { - const candidate = resolve( - current, - "packages", - "mcp-server", - "dist", - "cli.js", - ); - if (existsSync(candidate)) return candidate; - const parent = dirname(current); - if (parent === current) break; - current = parent; - } - return null; -} -/** - * Resolve the bundled workflow MCP CLI path from env anchors or module search. - */ -function getBundledWorkflowMcpCliPath(env) { - const envAnchors = [ - env.SF_BIN_PATH?.trim(), - env.SF_CLI_PATH?.trim(), - env.SF_WORKFLOW_PATH?.trim(), - ].filter((value) => typeof value === "string" && value.length > 0); - for (const anchor of envAnchors) { - const candidate = findWorkflowCliFromAncestorPath(anchor); - if (candidate) return candidate; - } - const candidates = [ - resolve( - fileURLToPath( - new URL("../../../../packages/mcp-server/src/cli.ts", import.meta.url), - ), - ), - resolve( - fileURLToPath( - new URL( - "../../../../../packages/mcp-server/src/cli.ts", - import.meta.url, - ), - ), - ), - resolve( - fileURLToPath( - new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url), - ), - ), - resolve( - fileURLToPath( - new URL( - "../../../../../packages/mcp-server/dist/cli.js", - import.meta.url, - ), - ), - ), - ]; - for (const bundledCli of candidates) { - if (existsSync(bundledCli)) return bundledCli; - } - return null; -} -/** - * Resolve the bundled workflow tool executors module path. - */ -function getBundledWorkflowExecutorModulePath() { - const candidates = [ - resolve( - fileURLToPath( - new URL("./tools/workflow-tool-executors.js", import.meta.url), - ), - ), - resolve( - fileURLToPath( - new URL("./tools/workflow-tool-executors.ts", import.meta.url), - ), - ), - resolve( - fileURLToPath( - new URL( - "../../../../dist/resources/extensions/sf/tools/workflow-tool-executors.js", - import.meta.url, - ), - ), - ), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} -/** - * Resolve the bundled write-gate module path. - */ -function getBundledWorkflowWriteGateModulePath() { - const candidates = [ - resolve( - fileURLToPath(new URL("./bootstrap/write-gate.js", import.meta.url)), - ), - resolve( - fileURLToPath(new URL("./bootstrap/write-gate.ts", import.meta.url)), - ), - resolve( - fileURLToPath( - new URL( - "../../../../dist/resources/extensions/sf/bootstrap/write-gate.js", - import.meta.url, - ), - ), - ), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} -function getResolveTsHookPath() { - const candidates = [ - resolve(fileURLToPath(new URL("./tests/resolve-ts.mjs", import.meta.url))), - resolve( - fileURLToPath( - new URL( - "../../../../src/resources/extensions/sf/tests/resolve-ts.mjs", - import.meta.url, - ), - ), - ), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} -function mergeNodeOptions(existing, additions) { - const tokens = (existing ?? "") - .split(/\s+/) - .map((value) => value.trim()) - .filter(Boolean); - for (const addition of additions) { - if (!tokens.includes(addition)) { - tokens.push(addition); - } - } - return tokens.length > 0 ? tokens.join(" ") : undefined; -} -function buildWorkflowLaunchEnv( - projectRoot, - sfCliPath, - explicitEnv, - workflowCliPath, -) { - const executorModulePath = getBundledWorkflowExecutorModulePath(); - const writeGateModulePath = getBundledWorkflowWriteGateModulePath(); - const resolveTsHookPath = getResolveTsHookPath(); - const wantsSourceTs = - Boolean(resolveTsHookPath) && - ((workflowCliPath?.endsWith(".ts") ?? false) || - (executorModulePath?.endsWith(".ts") ?? false) || - (writeGateModulePath?.endsWith(".ts") ?? false)); - const nodeOptions = wantsSourceTs - ? mergeNodeOptions(explicitEnv?.NODE_OPTIONS, [ - "--experimental-strip-types", - `--import=${pathToFileURL(resolveTsHookPath).href}`, - ]) - : explicitEnv?.NODE_OPTIONS; - return { - ...(explicitEnv ?? {}), - ...(sfCliPath ? { SF_CLI_PATH: sfCliPath } : {}), - ...(executorModulePath - ? { SF_WORKFLOW_EXECUTORS_MODULE: executorModulePath } - : {}), - ...(writeGateModulePath - ? { SF_WORKFLOW_WRITE_GATE_MODULE: writeGateModulePath } - : {}), - ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}), - SF_PERSIST_WRITE_GATE_STATE: "1", - SF_WORKFLOW_PROJECT_ROOT: projectRoot, - }; -} -export function detectWorkflowMcpLaunchConfig( - projectRoot = process.cwd(), - env = process.env, -) { - const name = env.SF_WORKFLOW_MCP_NAME?.trim() || "sf-workflow"; - const explicitCommand = env.SF_WORKFLOW_MCP_COMMAND?.trim(); - const explicitArgs = parseJsonEnv(env, "SF_WORKFLOW_MCP_ARGS"); - const explicitEnv = parseJsonEnv(env, "SF_WORKFLOW_MCP_ENV"); - const explicitCwd = env.SF_WORKFLOW_MCP_CWD?.trim(); - const sfCliPath = env.SF_CLI_PATH?.trim() || env.SF_BIN_PATH?.trim(); - const workflowProjectRoot = - explicitEnv?.SF_WORKFLOW_PROJECT_ROOT?.trim() || - env.SF_WORKFLOW_PROJECT_ROOT?.trim() || - env.SF_PROJECT_ROOT?.trim() || - explicitCwd || - projectRoot; - const resolvedWorkflowProjectRoot = resolve(workflowProjectRoot); - if (explicitCommand) { - const launchEnv = buildWorkflowLaunchEnv( - resolve(workflowProjectRoot), - sfCliPath, - explicitEnv, - ); - return { - name, - command: explicitCommand, - args: - Array.isArray(explicitArgs) && explicitArgs.length > 0 - ? explicitArgs.map(String) - : undefined, - cwd: explicitCwd || undefined, - env: Object.keys(launchEnv).length > 0 ? launchEnv : undefined, - }; - } - const distCli = resolve( - resolvedWorkflowProjectRoot, - "packages", - "mcp-server", - "dist", - "cli.js", - ); - if (existsSync(distCli)) { - return { - name, - command: process.execPath, - args: [distCli], - cwd: resolvedWorkflowProjectRoot, - env: buildWorkflowLaunchEnv( - resolvedWorkflowProjectRoot, - sfCliPath, - undefined, - distCli, - ), - }; - } - const bundledCli = getBundledWorkflowMcpCliPath(env); - if (bundledCli) { - return { - name, - command: process.execPath, - args: [bundledCli], - cwd: resolvedWorkflowProjectRoot, - env: buildWorkflowLaunchEnv( - resolvedWorkflowProjectRoot, - sfCliPath, - undefined, - bundledCli, - ), - }; - } - const binPath = lookupCommand("sf-mcp-server"); - if (binPath) { - return { - name, - command: binPath, - env: buildWorkflowLaunchEnv(resolvedWorkflowProjectRoot, sfCliPath), - }; - } - return null; -} -export function buildWorkflowMcpServers( - projectRoot = process.cwd(), - env = process.env, -) { - const launch = detectWorkflowMcpLaunchConfig(projectRoot, env); - if (!launch) return undefined; - return { - [launch.name]: { - command: launch.command, - ...(launch.args && launch.args.length > 0 ? { args: launch.args } : {}), - ...(launch.env ? { env: launch.env } : {}), - ...(launch.cwd ? { cwd: launch.cwd } : {}), - }, - }; -} export function getRequiredWorkflowToolsForGuidedUnit(unitType) { switch (unitType) { case "discuss-milestone": @@ -385,19 +52,13 @@ export function getRequiredWorkflowToolsForAutoUnit(unitType) { } } export function usesWorkflowMcpTransport(authMode, baseUrl) { - return ( - authMode === "externalCli" && - typeof baseUrl === "string" && - baseUrl.startsWith("local://") - ); + void authMode; + void baseUrl; + return false; } export function supportsStructuredQuestions(activeTools, options = {}) { + void options; if (!activeTools.includes("ask_user_questions")) return false; - // Workflow MCP currently exposes ask_user_questions via MCP form elicitation. - // Local external CLI transports such as Claude Code can invoke the tool, but - // do not reliably complete that elicitation round-trip yet, so guided discuss - // prompts must fall back to plain-text questioning. - if (usesWorkflowMcpTransport(options.authMode, options.baseUrl)) return false; return true; } export function getWorkflowTransportSupportError( @@ -405,20 +66,8 @@ export function getWorkflowTransportSupportError( requiredTools, options = {}, ) { - if (!provider || requiredTools.length === 0) return null; - if (!usesWorkflowMcpTransport(options.authMode, options.baseUrl)) return null; - const projectRoot = options.projectRoot ?? process.cwd(); - const env = options.env ?? process.env; - const launch = detectWorkflowMcpLaunchConfig(projectRoot, env); - const surface = options.surface ?? "workflow dispatch"; - const unitLabel = options.unitType ? ` for ${options.unitType}` : ""; - const providerLabel = `"${provider}"`; - if (!launch) { - return `Provider ${providerLabel} cannot run ${surface}${unitLabel}: the SF workflow MCP server is not configured or discoverable. Detected Claude Code model but no workflow MCP. Please run /sf mcp init . from your project root to configure MCP. Note: local-transport MCP (local://) does not support structured questions (ask_user_questions elicitation) — structured-question flows require a remote MCP transport. You can also configure SF_WORKFLOW_MCP_COMMAND, build packages/mcp-server/dist/cli.js, or install sf-mcp-server on PATH.`; - } - const missing = [...new Set(requiredTools)].filter( - (tool) => !MCP_WORKFLOW_TOOL_SURFACE.has(tool), - ); - if (missing.length === 0) return null; - return `Provider ${providerLabel} cannot run ${surface}${unitLabel}: this unit requires ${missing.join(", ")}, but the workflow MCP transport currently exposes only ${Array.from(MCP_WORKFLOW_TOOL_SURFACE).sort().join(", ")}.`; + void provider; + void requiredTools; + void options; + return null; } diff --git a/src/resources/extensions/sf/workflow-plugins.js b/src/resources/extensions/sf/workflow-plugins.js index b68eaa196..ab48cbe95 100644 --- a/src/resources/extensions/sf/workflow-plugins.js +++ b/src/resources/extensions/sf/workflow-plugins.js @@ -8,7 +8,7 @@ * oneshot — prompt-only, no state or scaffolding * yaml-step — CustomWorkflowEngine run with GRAPH.yaml * markdown-phase — STATE.json + phase gates (current md template behavior) - * auto-milestone — hooks into /sf auto pipeline (full-project only) + * auto-milestone — hooks into /sf autonomous pipeline (full-project only) * * Precedence: project > global > bundled. Same-named file wins. */ diff --git a/src/resources/extensions/sf/worktree-manager.js b/src/resources/extensions/sf/worktree-manager.js index 8eadbbe6a..551cadba8 100644 --- a/src/resources/extensions/sf/worktree-manager.js +++ b/src/resources/extensions/sf/worktree-manager.js @@ -604,7 +604,7 @@ export function removeWorktree(basePath, name, opts = {}) { // worktree remove), force-remove the git internal worktree metadata first, // then remove the filesystem directory. Without this, the .git/worktrees/ // lock prevents rmSync from cleaning up, and the orphaned worktree directory - // causes every subsequent `/sf auto` to re-enter the stale worktree. + // causes every subsequent `/sf autonomous` to re-enter the stale worktree. if (existsSync(resolvedWtPath)) { try { const wtInternalDir = join(basePath, ".git", "worktrees", name); diff --git a/src/resources/skills/forensics/SKILL.md b/src/resources/skills/forensics/SKILL.md index 97c84dc17..9014fb088 100644 --- a/src/resources/skills/forensics/SKILL.md +++ b/src/resources/skills/forensics/SKILL.md @@ -1,6 +1,6 @@ --- name: forensics -description: Post-mortem a failed sf auto-mode run. Traces from symptom to root cause using `.sf/activity/*.jsonl`, `.sf/journal/YYYY-MM-DD.jsonl`, `.sf/metrics.json`, and `.sf/auto.lock`. Produces a filing-ready bug report with file:line references and a concrete fix suggestion. Use when asked to "forensics", "post-mortem", "why did auto-mode fail", "trace the stuck loop", "debug the crash", after `/sf forensics` is invoked, or when a session ended in an unexpected terminal state. Reads existing artifacts — does NOT re-run anything. +description: Post-mortem a failed sf autonomous-mode run. Traces from symptom to root cause using `.sf/activity/*.jsonl`, `.sf/journal/YYYY-MM-DD.jsonl`, `.sf/metrics.json`, and `.sf/auto.lock`. Produces a filing-ready bug report with file:line references and a concrete fix suggestion. Use when asked to "forensics", "post-mortem", "why did autonomous-mode fail", "trace the stuck loop", "debug the crash", after `/sf forensics` is invoked, or when a session ended in an unexpected terminal state. Reads existing artifacts — does NOT re-run anything. --- diff --git a/src/tests/auto-mode-piped.test.ts b/src/tests/auto-mode-piped.test.ts index f35838561..d1f024439 100644 --- a/src/tests/auto-mode-piped.test.ts +++ b/src/tests/auto-mode-piped.test.ts @@ -1,7 +1,7 @@ /** - * Tests for autonomous routing — verifies that `autonomous` and `auto` are - * recognized as subcommand aliases for `headless auto` so they don't fall through to the - * interactive TUI, which hangs when stdin/stdout are piped. + * Tests for autonomous routing — verifies that `autonomous` is routed to + * headless mode so it doesn't fall through to the interactive TUI, which hangs + * when stdin/stdout are piped. * * Regression test for #2732. */ @@ -15,13 +15,13 @@ import { test } from "vitest"; const projectRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); // --------------------------------------------------------------------------- -// Source-level verification: cli.ts must handle 'auto' before TUI +// Source-level verification: cli.ts must handle autonomous before TUI // --------------------------------------------------------------------------- /** - * Read cli.ts and verify the 'auto' subcommand is routed before the + * Read cli.ts and verify the autonomous subcommand is routed before the * interactive TUI code path. This is the definitive test — if cli.ts doesn't - * handle 'auto', piped invocations will hang (#2732). + * handle autonomous mode, piped invocations will hang (#2732). */ function cliSourceHandlesAutonomousBeforeTUI(): boolean { const cliSource = readFileSync(join(projectRoot, "src", "cli.ts"), "utf-8"); @@ -44,7 +44,7 @@ function cliSourceHandlesAutonomousBeforeTUI(): boolean { } // ═══════════════════════════════════════════════════════════════════════════ -// Core regression test: `sf auto` must be handled before TUI (#2732) +// Core regression test: `sf autonomous` must be handled before TUI (#2732) // ═══════════════════════════════════════════════════════════════════════════ test("cli.ts handles `autonomous` subcommand before interactive TUI (#2732)", () => { @@ -76,13 +76,12 @@ test("cli.ts routes `autonomous` to headless runner", () => { // Verify piped-mode hint in error message when auto mode is not available // ═══════════════════════════════════════════════════════════════════════════ -test("TTY error message mentions `sf auto` as a non-interactive alternative", () => { +test("TTY error message mentions non-interactive alternatives", () => { const cliSource = readFileSync(join(projectRoot, "src", "cli.ts"), "utf-8"); - // The TTY error message should mention auto as an alternative assert.ok( - cliSource.includes("sf auto") || cliSource.includes("sf headless"), - "TTY error hints should mention headless/auto mode as alternatives", + cliSource.includes("sf autonomous") || cliSource.includes("sf headless"), + "TTY error hints should mention headless/autonomous mode as alternatives", ); }); diff --git a/src/tests/auto-piped-io.test.ts b/src/tests/auto-piped-io.test.ts index ef04d66e8..cbda99391 100644 --- a/src/tests/auto-piped-io.test.ts +++ b/src/tests/auto-piped-io.test.ts @@ -1,7 +1,7 @@ /** - * Tests for auto-mode piped I/O detection (#2732). + * Tests for autonomous-mode piped I/O detection (#2732). * - * When `sf auto` is run with piped stdout (e.g. `sf auto | cat`), + * When `sf autonomous` is run with piped stdout (e.g. `sf autonomous | cat`), * the CLI should detect the non-TTY stdout and redirect to headless * mode instead of hanging in interactive mode trying to set up a TUI * on a non-terminal output stream. @@ -34,7 +34,7 @@ const EXPLICIT_SUBCOMMANDS = new Set([ * Detect whether the current subcommand should be auto-redirected * to headless mode when stdout is not a TTY. * - * Returns true when: the subcommand is "auto" or "autonomous" AND stdout is piped. + * Returns true when: the subcommand is "autonomous", or legacy "auto", AND stdout is piped. */ function shouldRedirectAutoToHeadless( subcommand: string | undefined, @@ -65,7 +65,7 @@ function isExplicitSubcommand(subcommand: string | undefined): boolean { // ─── shouldRedirectAutoToHeadless ───────────────────────────────────────── -test("redirects 'auto' to headless when stdout is piped", () => { +test("redirects legacy 'auto' to headless when stdout is piped", () => { assert.ok(shouldRedirectAutoToHeadless("auto", false)); }); @@ -73,7 +73,7 @@ test("redirects 'autonomous' to headless when stdout is piped", () => { assert.ok(shouldRedirectAutoToHeadless("autonomous", false)); }); -test("does NOT redirect 'auto' when stdout is a TTY", () => { +test("does NOT redirect legacy 'auto' when stdout is a TTY", () => { assert.ok(!shouldRedirectAutoToHeadless("auto", true)); }); @@ -114,7 +114,7 @@ test("identifies explicitly handled subcommands", () => { assert.ok(isExplicitSubcommand("web")); }); -test("does NOT identify 'auto' as explicit subcommand", () => { +test("does NOT identify legacy 'auto' as explicit subcommand", () => { assert.ok(!isExplicitSubcommand("auto")); }); @@ -122,23 +122,21 @@ test("does NOT identify undefined as explicit subcommand", () => { assert.ok(!isExplicitSubcommand(undefined)); }); -// ─── End-to-end scenario: sf auto | cat ────────────────────────────────── +// ─── End-to-end scenario: sf autonomous | cat ──────────────────────────── -test("scenario: 'sf auto 2>&1 | cat' — should redirect to headless", () => { - // Simulates: subcommand = "auto", stdin is TTY, stdout is piped - const subcommand = "auto"; +test("scenario: 'sf autonomous 2>&1 | cat' — should redirect to headless", () => { + const subcommand = "autonomous"; const stdinIsTTY = true; const stdoutIsTTY = false; // Interactive mode should be blocked assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); - // Auto should be redirected to headless assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); }); -test("scenario: 'sf auto > /tmp/output.txt' — should redirect to headless", () => { - const subcommand = "auto"; +test("scenario: 'sf autonomous > /tmp/output.txt' — should redirect to headless", () => { + const subcommand = "autonomous"; const stdinIsTTY = true; const stdoutIsTTY = false; @@ -146,8 +144,8 @@ test("scenario: 'sf auto > /tmp/output.txt' — should redirect to headless", () assert.ok(shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); }); -test("scenario: 'sf auto' in terminal — normal interactive mode", () => { - const subcommand = "auto"; +test("scenario: 'sf autonomous' in terminal — normal interactive mode", () => { + const subcommand = "autonomous"; const stdinIsTTY = true; const stdoutIsTTY = true; @@ -155,8 +153,8 @@ test("scenario: 'sf auto' in terminal — normal interactive mode", () => { assert.ok(!shouldRedirectAutoToHeadless(subcommand, stdoutIsTTY)); }); -test("scenario: 'echo msg | sf auto' — stdin piped, should redirect", () => { - const subcommand = "auto"; +test("scenario: 'echo msg | sf autonomous' — stdin piped, should redirect", () => { + const subcommand = "autonomous"; const stdinIsTTY = false; const stdoutIsTTY = true; // stdout is TTY even though stdin is piped @@ -166,8 +164,8 @@ test("scenario: 'echo msg | sf auto' — stdin piped, should redirect", () => { assert.ok(!canEnterInteractiveMode(stdinIsTTY, stdoutIsTTY)); }); -test("scenario: 'echo msg | sf auto | cat' — both piped", () => { - const subcommand = "auto"; +test("scenario: 'echo msg | sf autonomous | cat' — both piped", () => { + const subcommand = "autonomous"; const stdinIsTTY = false; const stdoutIsTTY = false; diff --git a/src/tests/headless-cli-surface.test.ts b/src/tests/headless-cli-surface.test.ts index b19e69759..edd9285c8 100644 --- a/src/tests/headless-cli-surface.test.ts +++ b/src/tests/headless-cli-surface.test.ts @@ -49,7 +49,7 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { timeout: 300_000, json: false, outputFormat: "text", - command: "auto", + command: "autonomous", commandArgs: [], }; @@ -109,7 +109,12 @@ function parseHeadlessArgs(argv: string[]): HeadlessOptions { options.bare = true; } } else if (!commandSeen) { - options.command = arg === "autonomous" ? "auto" : arg; + if (arg === "autonomous" || arg === "auto") { + options.command = "autonomous"; + options.auto = true; + } else { + options.command = arg; + } commandSeen = true; } else { options.commandArgs.push(arg); @@ -128,7 +133,7 @@ test("--output-format text sets outputFormat to text", () => { "headless", "--output-format", "text", - "auto", + "autonomous", ]); assert.equal(opts.outputFormat, "text"); assert.equal(opts.json, false); @@ -141,7 +146,7 @@ test("--output-format json sets outputFormat to json and json=true", () => { "headless", "--output-format", "json", - "auto", + "autonomous", ]); assert.equal(opts.outputFormat, "json"); assert.equal(opts.json, true); @@ -154,21 +159,21 @@ test("--output-format stream-json sets outputFormat to stream-json and json=true "headless", "--output-format", "stream-json", - "auto", + "autonomous", ]); assert.equal(opts.outputFormat, "stream-json"); assert.equal(opts.json, true); }); test("default output format is text", () => { - const opts = parseHeadlessArgs(["node", "sf", "headless", "auto"]); + const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]); assert.equal(opts.outputFormat, "text"); assert.equal(opts.json, false); }); -test("autonomous command is accepted as headless auto alias", () => { +test("autonomous command is accepted as headless command", () => { const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); assert.deepEqual(opts.commandArgs, []); }); @@ -181,13 +186,13 @@ test("autonomous command preserves command arguments", () => { "M001", "extra-context", ]); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); assert.deepEqual(opts.commandArgs, ["M001", "extra-context"]); }); -test("auto command preserves command arguments", () => { +test("legacy auto command normalizes to autonomous", () => { const opts = parseHeadlessArgs(["node", "sf", "headless", "auto", "M001"]); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); assert.deepEqual(opts.commandArgs, ["M001"]); }); @@ -200,7 +205,7 @@ test("invalid --output-format value throws", () => { "headless", "--output-format", "yaml", - "auto", + "autonomous", ]), /Invalid output format: yaml/, ); @@ -215,7 +220,7 @@ test("invalid --output-format value (empty) throws", () => { "headless", "--output-format", "xml", - "auto", + "autonomous", ]), /Invalid output format/, ); @@ -224,7 +229,13 @@ test("invalid --output-format value (empty) throws", () => { // ─── --json backward compatibility ───────────────────────────────────────── test("--json is alias for --output-format stream-json", () => { - const opts = parseHeadlessArgs(["node", "sf", "headless", "--json", "auto"]); + const opts = parseHeadlessArgs([ + "node", + "sf", + "headless", + "--json", + "autonomous", + ]); assert.equal(opts.outputFormat, "stream-json"); assert.equal(opts.json, true); }); @@ -237,7 +248,7 @@ test("--json before --output-format json: last writer wins", () => { "--json", "--output-format", "json", - "auto", + "autonomous", ]); assert.equal(opts.outputFormat, "json"); assert.equal(opts.json, true); @@ -252,14 +263,14 @@ test("--resume parses session ID", () => { "headless", "--resume", "abc-123", - "auto", + "autonomous", ]); assert.equal(opts.resumeSession, "abc-123"); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); }); test("no --resume means undefined", () => { - const opts = parseHeadlessArgs(["node", "sf", "headless", "auto"]); + const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]); assert.equal(opts.resumeSession, undefined); }); @@ -392,7 +403,7 @@ test("--events still works with new outputFormat default", () => { "headless", "--events", "agent_end,tool_execution_start", - "auto", + "autonomous", ]); assert.ok(opts.eventFilter instanceof Set); assert.equal(opts.eventFilter!.size, 2); @@ -407,7 +418,7 @@ test("--timeout still works", () => { "headless", "--timeout", "60000", - "auto", + "autonomous", ]); assert.equal(opts.timeout, 60000); }); @@ -418,7 +429,7 @@ test("--supervised still works and implies stream-json", () => { "sf", "headless", "--supervised", - "auto", + "autonomous", ]); assert.equal(opts.supervised, true); assert.equal(opts.json, true); @@ -432,7 +443,7 @@ test("--answers still works", () => { "headless", "--answers", "answers.json", - "auto", + "autonomous", ]); assert.equal(opts.answers, "answers.json"); }); @@ -454,26 +465,32 @@ test("combined flags parse correctly", () => { "--resume", "sess-xyz", "--verbose", - "auto", + "autonomous", ]); assert.equal(opts.outputFormat, "json"); assert.equal(opts.json, true); assert.equal(opts.timeout, 120000); assert.equal(opts.resumeSession, "sess-xyz"); assert.equal(opts.verbose, true); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); }); // ─── --bare flag ─────────────────────────────────────────────────────────── test("--bare sets bare to true", () => { - const opts = parseHeadlessArgs(["node", "sf", "headless", "--bare", "auto"]); + const opts = parseHeadlessArgs([ + "node", + "sf", + "headless", + "--bare", + "autonomous", + ]); assert.equal(opts.bare, true); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); }); test("no --bare means bare is undefined", () => { - const opts = parseHeadlessArgs(["node", "sf", "headless", "auto"]); + const opts = parseHeadlessArgs(["node", "sf", "headless", "autonomous"]); assert.equal(opts.bare, undefined); }); @@ -484,7 +501,7 @@ test("--bare is a boolean flag (no value needed)", () => { "headless", "--bare", "--json", - "auto", + "autonomous", ]); assert.equal(opts.bare, true); assert.equal(opts.json, true); @@ -498,12 +515,12 @@ test("--bare combined with --output-format json", () => { "--bare", "--output-format", "json", - "auto", + "autonomous", ]); assert.equal(opts.bare, true); assert.equal(opts.outputFormat, "json"); assert.equal(opts.json, true); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); }); // ─── Command-first ordering (flags after command) ───────────────────────── @@ -564,10 +581,10 @@ test("--bare does not affect other flags", () => { "60000", "--resume", "sess-abc", - "auto", + "autonomous", ]); assert.equal(opts.bare, true); assert.equal(opts.timeout, 60000); assert.equal(opts.resumeSession, "sess-abc"); - assert.equal(opts.command, "auto"); + assert.equal(opts.command, "autonomous"); }); diff --git a/src/tests/headless-detection.test.ts b/src/tests/headless-detection.test.ts index 289346fb1..8b3952095 100644 --- a/src/tests/headless-detection.test.ts +++ b/src/tests/headless-detection.test.ts @@ -164,7 +164,7 @@ test("detects blocked notification with 'Blocked:' prefix", () => { test("detects inline 'Blocked:' message", () => { assert.ok( isBlockedNotification( - makeNotify("Blocked: no active milestone. Fix and run /sf auto."), + makeNotify("Blocked: no active milestone. Fix and run /sf autonomous."), ), ); }); diff --git a/src/tests/integration/web-command-parity-contract.test.ts b/src/tests/integration/web-command-parity-contract.test.ts index d6c571fd0..bd2982a34 100644 --- a/src/tests/integration/web-command-parity-contract.test.ts +++ b/src/tests/integration/web-command-parity-contract.test.ts @@ -289,7 +289,7 @@ const EXPECTED_SF_OUTCOMES = new Map< ["cleanup", "surface"], ["queue", "surface"], // Bridge passthrough (9) - ["auto", "prompt"], + ["autonomous", "prompt"], ["next", "prompt"], ["stop", "prompt"], ["pause", "prompt"], @@ -436,7 +436,7 @@ describe("SF dispatch edge cases", () => { }); test("SF passthrough commands produce no terminal notice", () => { - const outcome = dispatchBrowserSlashCommand("/sf auto"); + const outcome = dispatchBrowserSlashCommand("/sf autonomous"); const notice = getBrowserSlashCommandTerminalNotice(outcome); assert.equal( notice, diff --git a/src/tests/integration/web-mode-assembled.test.ts b/src/tests/integration/web-mode-assembled.test.ts index f81f20903..5f6cd0c8c 100644 --- a/src/tests/integration/web-mode-assembled.test.ts +++ b/src/tests/integration/web-mode-assembled.test.ts @@ -1294,8 +1294,8 @@ test("assembled slash-command behavior keeps built-ins safe while preserving SF assert.equal(sfSurfaceResult.outcome.surface, "sf-status"); assert.equal(sfSurfaceResult.status, null); - // /sf auto is a passthrough subcommand — reaches the bridge as a prompt - const sfPromptResult = await submitBrowserInput("/sf auto"); + // /sf autonomous is a passthrough subcommand — reaches the bridge as a prompt + const sfPromptResult = await submitBrowserInput("/sf autonomous"); assert.equal(sfPromptResult.outcome.kind, "prompt"); assert.equal(sfPromptResult.status, 200); assert.equal(sfPromptResult.body.command, "prompt"); @@ -1311,7 +1311,7 @@ test("assembled slash-command behavior keeps built-ins safe while preserving SF ); assert.equal( promptCommand?.message, - "/sf auto", + "/sf autonomous", "SF passthrough commands must stay on the extension prompt path", ); }); diff --git a/src/tests/integration/web-state-surfaces-contract.test.ts b/src/tests/integration/web-state-surfaces-contract.test.ts index f52d56118..6a22e8732 100644 --- a/src/tests/integration/web-state-surfaces-contract.test.ts +++ b/src/tests/integration/web-state-surfaces-contract.test.ts @@ -828,8 +828,8 @@ test("workflow action surfaces route new-milestone CTAs through the shared comma ); assert.doesNotMatch( chatSource, - /buildPromptCommand\("\/sf auto", bridge\)/, - "chat-mode.tsx must not hardcode a special /sf auto path for new-milestone CTA dispatch", + /buildPromptCommand\("\/sf autonomous", bridge\)/, + "chat-mode.tsx must not hardcode a special /sf autonomous path for new-milestone CTA dispatch", ); }); diff --git a/src/tests/mcp-createRequire.test.ts b/src/tests/mcp-createRequire.test.ts deleted file mode 100644 index 3a50a99af..000000000 --- a/src/tests/mcp-createRequire.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Regression test for #3603 / #3914 — MCP server subpath imports. - * - * @modelcontextprotocol/sdk's package.json exports map uses a wildcard - * `./*` → `./dist/cjs/*` with no `.js` suffix, so bare subpath specifiers - * like `@modelcontextprotocol/sdk/server/stdio` resolve to a file that - * doesn't exist. Historically the workaround used `createRequire` so the - * CJS resolver auto-appended `.js`; that no longer works with current - * Node + SDK versions (#3914). - * - * The reliable convention (used in packages/mcp-server/{server,cli}.ts) - * is to write the `.js` suffix explicitly on every subpath import. This - * test locks that convention in so regressions can't silently reintroduce - * the bare subpath form or the broken createRequire-based resolution. - */ - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, test } from "vitest"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const source = readFileSync(join(__dirname, "..", "mcp-server.ts"), "utf-8"); - -describe("MCP server SDK subpath imports (#3603 / #3914)", () => { - test("server/index.js subpath is imported with explicit .js suffix", () => { - assert.match( - source, - /await import\(`\$\{MCP_PKG\}\/server\/index\.js`\)/, - // biome-ignore lint/suspicious/noTemplateCurlyInString: literal text describing expected import syntax - "server import must use `${MCP_PKG}/server/index.js` to satisfy the wildcard export map", - ); - }); - - test("server/stdio.js subpath is imported with explicit .js suffix", () => { - assert.match( - source, - /await import\(`\$\{MCP_PKG\}\/server\/stdio\.js`\)/, - // biome-ignore lint/suspicious/noTemplateCurlyInString: literal text describing expected import syntax - "stdio import must use `${MCP_PKG}/server/stdio.js`", - ); - }); - - test("types.js subpath is imported with explicit .js suffix", () => { - assert.match( - source, - /await import\(`\$\{MCP_PKG\}\/types\.js`\)/, - // biome-ignore lint/suspicious/noTemplateCurlyInString: literal text describing expected import syntax - "types import must use `${MCP_PKG}/types.js`", - ); - }); - - test("legacy createRequire-based resolution is gone", () => { - // Only flag actual code, not the comment that explains the history. - // The import statement, variable declaration, and `_require.resolve(` call - // sites are the real regression surfaces. - assert.doesNotMatch( - source, - /^\s*import\s*\{\s*createRequire\s*\}\s*from/m, - "createRequire should not be imported from node:module", - ); - assert.doesNotMatch( - source, - /^\s*const\s+_require\s*=\s*createRequire/m, - "_require helper should not be created", - ); - assert.doesNotMatch( - source, - /_require\.resolve\(/, - "_require.resolve should not be used for subpath resolution", - ); - }); -}); diff --git a/src/tests/mcp-server.test.ts b/src/tests/mcp-server.test.ts deleted file mode 100644 index 6e7cadd59..000000000 --- a/src/tests/mcp-server.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "vitest"; - -test("mcp-server module imports without errors", async () => { - const mod = await import("../mcp-server.ts"); - assert.ok(mod, "module should be importable"); - assert.strictEqual( - typeof mod.startMcpServer, - "function", - "startMcpServer should be a function", - ); -}); - -test("startMcpServer accepts the correct argument shape", async () => { - const { startMcpServer } = await import("../mcp-server.ts"); - - assert.strictEqual(typeof startMcpServer, "function"); - assert.strictEqual( - startMcpServer.length, - 1, - "startMcpServer should accept one argument", - ); -}); - -test("compiled MCP runtime dependencies resolve with explicit .js subpaths", async () => { - const stdioMod = await import("@modelcontextprotocol/sdk/server/stdio.js"); - const typesMod = await import("@modelcontextprotocol/sdk/types.js"); - - assert.strictEqual(typeof stdioMod.StdioServerTransport, "function"); - assert.ok( - typesMod.ListToolsRequestSchema, - "ListToolsRequestSchema should be exported", - ); - assert.ok( - typesMod.CallToolRequestSchema, - "CallToolRequestSchema should be exported", - ); -}); diff --git a/src/tests/package-mcp-server-elicitation.test.ts b/src/tests/package-mcp-server-elicitation.test.ts deleted file mode 100644 index 4ef60c5b2..000000000 --- a/src/tests/package-mcp-server-elicitation.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import assert from "node:assert/strict"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { test } from "vitest"; - -import { - buildAskUserQuestionsElicitRequest, - buildAskUserQuestionsRoundResult, - createMcpServer, - formatAskUserQuestionsElicitResult, -} from "../../packages/mcp-server/src/server.js"; - -function createSessionManagerStub() { - return { - startSession: async () => { - throw new Error("not implemented in test"); - }, - getSession: () => undefined, - getResult: () => undefined, - cancelSession: async () => {}, - resolveBlocker: async () => {}, - }; -} - -async function createConnectedClient(options?: { - onElicit?: (params: unknown) => Promise; -}) { - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - - const { server } = await createMcpServer(createSessionManagerStub() as never); - const client = new Client( - { - name: "test-client", - version: "0.0.0", - }, - { - capabilities: { - elicitation: {}, - }, - }, - ); - - if (options?.onElicit) { - client.setRequestHandler(ElicitRequestSchema, options.onElicit); - } - - await Promise.all([ - server.connect(serverTransport), - client.connect(clientTransport), - ]); - - return { - client, - close: async () => { - await client.close(); - await server.close(); - }, - }; -} - -test("package MCP server exposes ask_user_questions over listTools", async () => { - const { client, close } = await createConnectedClient(); - - try { - const tools = await client.listTools(); - assert.ok(tools.tools.some((tool) => tool.name === "ask_user_questions")); - } finally { - await close(); - } -}); - -test("ask_user_questions returns the packaged answers JSON shape for form elicitation", async () => { - const { client, close } = await createConnectedClient({ - onElicit: async (request) => { - const elicitation = - ( - request as { - params?: { - message: string; - requestedSchema: { - properties: Record; - required?: string[]; - }; - }; - } - ).params ?? - (request as { - message: string; - requestedSchema: { - properties: Record; - required?: string[]; - }; - }); - assert.match(elicitation.message, /Please answer the following question/); - assert.ok(elicitation.requestedSchema.properties.deployment); - assert.ok(elicitation.requestedSchema.properties["deployment__note"]); - assert.ok(elicitation.requestedSchema.required?.includes("deployment")); - - return { - action: "accept", - content: { - deployment: "None of the above", - deployment__note: "Need hybrid deployment.", - }, - }; - }, - }); - - try { - const result = await client.callTool({ - name: "ask_user_questions", - arguments: { - questions: [ - { - id: "deployment", - header: "Deploy", - question: "Where will this run?", - options: [ - { label: "Cloud", description: "Managed hosting." }, - { - label: "On-prem", - description: "Runs in customer infrastructure.", - }, - ], - }, - ], - }, - }); - - const text = result.content.find((item) => item.type === "text"); - assert.ok(text && "text" in text); - assert.deepEqual(result.structuredContent, { - questions: [ - { - id: "deployment", - header: "Deploy", - question: "Where will this run?", - options: [ - { label: "Cloud", description: "Managed hosting." }, - { - label: "On-prem", - description: "Runs in customer infrastructure.", - }, - ], - }, - ], - response: { - endInterview: false, - answers: { - deployment: { - selected: "None of the above", - notes: "Need hybrid deployment.", - }, - }, - }, - cancelled: false, - }); - assert.equal( - text.text, - JSON.stringify({ - answers: { - deployment: { - answers: [ - "None of the above", - "user_note: Need hybrid deployment.", - ], - }, - }, - }), - ); - } finally { - await close(); - } -}); - -test("ask_user_questions returns an error result for invalid question payloads", async () => { - const { client, close } = await createConnectedClient(); - - try { - const result = await client.callTool({ - name: "ask_user_questions", - arguments: { - questions: [ - { - id: "broken", - header: "Broken", - question: "This payload is invalid", - options: [], - }, - ], - }, - }); - - const text = result.content.find((item) => item.type === "text"); - assert.ok(text && "text" in text); - assert.equal(result.isError, true); - assert.match(text.text, /requires non-empty options/i); - } finally { - await close(); - } -}); - -test("ask_user_questions returns the cancellation message when elicitation is declined", async () => { - const { client, close } = await createConnectedClient({ - onElicit: async () => ({ - action: "decline", - content: {}, - }), - }); - - try { - const result = await client.callTool({ - name: "ask_user_questions", - arguments: { - questions: [ - { - id: "continue", - header: "Continue", - question: "Continue?", - options: [ - { label: "Yes", description: "Proceed." }, - { label: "No", description: "Stop here." }, - ], - }, - ], - }, - }); - - const text = result.content.find((item) => item.type === "text"); - assert.ok(text && "text" in text); - assert.deepEqual(result.structuredContent, { - questions: [ - { - id: "continue", - header: "Continue", - question: "Continue?", - options: [ - { label: "Yes", description: "Proceed." }, - { label: "No", description: "Stop here." }, - ], - }, - ], - response: null, - cancelled: true, - }); - assert.equal( - text.text, - "ask_user_questions was cancelled before receiving a response", - ); - } finally { - await close(); - } -}); - -test("helper formatting stays aligned with the tool contract", () => { - const questions = [ - { - id: "focus_areas", - header: "Focus", - question: "Which areas matter most?", - allowMultiple: true, - options: [ - { label: "Frontend", description: "Prioritize the UI." }, - { label: "Backend", description: "Prioritize server logic." }, - ], - }, - ]; - - const request = buildAskUserQuestionsElicitRequest(questions); - assert.equal(request.mode, "form"); - assert.ok(request.requestedSchema.properties.focus_areas); - assert.ok(!request.requestedSchema.properties["focus_areas__note"]); - - const formatted = formatAskUserQuestionsElicitResult(questions, { - action: "accept", - content: { - focus_areas: ["Frontend"], - }, - }); - const round = buildAskUserQuestionsRoundResult(questions, { - action: "accept", - content: { - focus_areas: ["Frontend"], - }, - }); - - assert.equal( - formatted, - JSON.stringify({ - answers: { - focus_areas: { - answers: ["Frontend"], - }, - }, - }), - ); - assert.deepEqual(round.answers.focus_areas.selected, ["Frontend"]); -}); diff --git a/src/tests/parse-cli-args.test.ts b/src/tests/parse-cli-args.test.ts index 411fae24f..a853c918b 100644 --- a/src/tests/parse-cli-args.test.ts +++ b/src/tests/parse-cli-args.test.ts @@ -10,10 +10,6 @@ function parse(...args: string[]) { } describe("parseCliArgs — modes", () => { - test("accepts mcp mode (added during refactor)", () => { - assert.equal(parse("--mode", "mcp").mode, "mcp"); - }); - test("still accepts text/json/rpc modes", () => { assert.equal(parse("--mode", "text").mode, "text"); assert.equal(parse("--mode", "json").mode, "json"); diff --git a/tsconfig.extensions.json b/tsconfig.extensions.json index fb7b92239..ac645a5d9 100644 --- a/tsconfig.extensions.json +++ b/tsconfig.extensions.json @@ -21,7 +21,6 @@ "@singularity-forge/pi-tui": ["packages/pi-tui/src/index.ts"], "@singularity-forge/native": ["packages/native/src/index.ts"], "@singularity-forge/native/*": ["packages/rust-engine/src/*/index.ts"], - "@singularity-forge/mcp-server": ["packages/mcp-server/src/index.ts"], "@singularity-forge/rpc-client": ["packages/rpc-client/src/index.ts"] } }, diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 3cbe92322..95382d9cf 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -45,7 +45,7 @@ Use `@sf` in VS Code Chat (`Cmd+Shift+I`) to talk to the agent: ``` @sf refactor the auth module to use JWT -@sf /sf auto +@sf /sf autonomous @sf fix the errors in this file ``` diff --git a/vscode-extension/src/chat-participant.ts b/vscode-extension/src/chat-participant.ts index dad84c208..e683cd465 100644 --- a/vscode-extension/src/chat-participant.ts +++ b/vscode-extension/src/chat-participant.ts @@ -196,8 +196,8 @@ export function registerChatParticipant( title: "Check project status", }, { - prompt: "/sf auto", - label: "$(rocket) Run auto mode", + prompt: "/sf autonomous", + label: "$(rocket) Run autonomous", title: "Run autonomous mode", }, { diff --git a/vscode-extension/src/sidebar.ts b/vscode-extension/src/sidebar.ts index 5915421e3..c3f0366be 100644 --- a/vscode-extension/src/sidebar.ts +++ b/vscode-extension/src/sidebar.ts @@ -122,7 +122,7 @@ export class SfSidebarProvider implements vscode.WebviewViewProvider { await vscode.commands.executeCommand("sf.copyLastResponse"); break; case "autoMode": - await sendViaChat("@sf /sf auto"); + await sendViaChat("@sf /sf autonomous"); break; case "nextUnit": await sendViaChat("@sf /sf next"); diff --git a/web/lib/browser-slash-command-dispatch.ts b/web/lib/browser-slash-command-dispatch.ts index 04e5d917b..812d62dca 100644 --- a/web/lib/browser-slash-command-dispatch.ts +++ b/web/lib/browser-slash-command-dispatch.ts @@ -141,7 +141,7 @@ const SF_SURFACE_SUBCOMMANDS = new Map([ ]); const SF_PASSTHROUGH_SUBCOMMANDS = new Set([ - "auto", + "autonomous", "next", "stop", "pause", @@ -154,7 +154,7 @@ const SF_PASSTHROUGH_SUBCOMMANDS = new Set([ export const SF_HELP_TEXT = `Available /sf subcommands: -Workflow: next · auto · stop · pause · skip · queue · quick · capture · triage +Workflow: next · autonomous · stop · pause · skip · queue · quick · capture · triage Diagnostics: status · visualize · forensics · doctor · skill-health · inspect Context: knowledge · history · undo · discuss Settings: model · prefs · config · hooks · mode · steer